view weather_server/typescript/app/graph.ts @ 28:f817fa785c93

Make graph only consider visible points when setting range.
author Paul Fisher <paul@pfish.zone>
date Sun, 19 Jan 2020 17:05:11 -0500
parents 47987502bf4c
children beedfa8eaa3f
line wrap: on
line source

import { WeatherResponse } from './apis';
import { cToF, dewPointC } from './math';

/** The amount of time we will draw on the graph. */
const HISTORY_SECONDS = 60 * 60 * 4;
/** The amount of buffer room we will request before HISTORY_SECONDS. */
const BUFFER_SECONDS = 300;

/**
 * Sets up everything.
 * @param tempElement The element where temperature data is.
 * @param dewPointElement The element where the dew point is.
 */
export async function setUp(
    root: HTMLElement,
    tempElement: HTMLElement,
    dewPointElement: HTMLElement) {
    const nowTS = new Date().getTime() / 1000;
    const startTS = nowTS - HISTORY_SECONDS;
    const query = new URL('?', location.href);
    query.pathname = query.pathname + '/recent';
    query.searchParams.set('seconds', String(HISTORY_SECONDS + BUFFER_SECONDS));
    const results = await fetch(query.href);
    if (!results.ok) return;
    const data: WeatherResponse = await results.json();
    const readings = data.readings;
    if (readings.length === 0) return;
    root.classList.remove('plain');
    root.classList.add('fancy');

    const tempsF =
        readings.map(s => [s.sample_time, cToF(s.temp_c)] as Point);
    const dewPointsF = readings.map(
        s => [s.sample_time, cToF(dewPointC(s.temp_c, s.rh_pct))] as Point);
    setUpElement(tempElement, [startTS, nowTS], tempsF);
    setUpElement(dewPointElement, [startTS, nowTS], dewPointsF);
}

type Point = [number, number];

/**
 * Sets up charting for this element.
 * @param element The element to put a graph in.
 * @param timeRange The `[start, end]` of the time range.
 * @param data The data to chart.
 */
function setUpElement(
    element: HTMLElement, timeRange: [number, number], data: Point[]) {
    if (data.length === 0) return;
    const chart = new Chart(element, timeRange, data);
    chart.resize();
    addEventListener('resize', () => chart.resize());
}

/** The number of degrees that the graph shows vertically. */
const Y_DEGREE_RANGE = 10;

const LINE_WIDTH_PX = 2;

const FONT_SIZE = '40px';

class Chart {
    private readonly canvas: HTMLCanvasElement;
    private readonly unit: string;
    /**
     * Creates a new chart.
     * @param element The parent element to create `<canvas>`-based chart in.
     * @param timeRange `[start, end]` of the range to chart as Unix timestamps.
     * @param data The data to chart.
     */
    constructor(
        private readonly element: HTMLElement,
        private readonly timeRange: [number, number],
        private readonly data: Point[]) {
        this.canvas = document.createElement('canvas');
        this.element.insertBefore(this.canvas, element.firstChild);
        const unit = element.getElementsByClassName('unit')[0];
        this.unit = unit && unit.textContent || '';
    }

    resize() {
        const dpr = self.devicePixelRatio || 1;
        const [w, h] = this.size();
        const pxSize: Point = [w * dpr, h * dpr];
        this.canvas.width = pxSize[0];
        this.canvas.height = pxSize[1];
        const ctx = this.canvas.getContext('2d')!;
        ctx.clearRect(0, 0, pxSize[0], pxSize[1]);
        ctx.scale(dpr, dpr);
        this.redraw(ctx);
    }

    private redraw(ctx: CanvasRenderingContext2D) {
        const stroke = getComputedStyle(this.element).color!;
        const family = getComputedStyle(this.element).fontFamily!;
        ctx.strokeStyle = stroke;
        ctx.lineJoin = 'round';
        ctx.lineWidth = LINE_WIDTH_PX;
        ctx.font = `bold ${FONT_SIZE} ${family}`;

        const onScreenData = this.data.filter(
            ([time, _]) => this.timeRange[0] <= time);

        const yRange = calculateYRange(onScreenData.map(d => d[1]));

        const [fullW, fullH] = this.size();
        const [xPad, yMargin] = this.measureMargin(ctx);
        const graphSize: Point = [fullW - xPad, fullH];

        ctx.beginPath();
        for (const pt of this.data) {
            const projected = project(pt, graphSize, this.timeRange, yRange);
            ctx.lineTo(...projected);
        }
        ctx.stroke();
        ctx.beginPath();
        const lastPt = this.data[this.data.length - 1];
        const center = project(lastPt, graphSize, this.timeRange, yRange);
        ctx.ellipse(
            center[0],
            center[1],
            1.5 * LINE_WIDTH_PX,
            1.5 * LINE_WIDTH_PX,
            0,
            0,
            2 * Math.PI);
        ctx.fillStyle = getComputedStyle(this.element).backgroundColor!;
        ctx.fill();
        ctx.stroke();

        ctx.fillStyle = stroke;
        ctx.textAlign = 'left';
        ctx.textBaseline = 'top';
        ctx.fillText(
            `${niceNumber(lastPt[1])} ${this.unit}`,
            center[0] + 5 * LINE_WIDTH_PX,
            center[1] + yMargin);
    }

    private measureMargin(ctx: CanvasRenderingContext2D): [number, number] {
        const bbox = ctx.measureText(`−99 ${this.unit}`);
        const margin =
            5 * LINE_WIDTH_PX +  // margin to text
            bbox.width +  // max (?) width of text
            16;  // Pixel margin to wall.
        return [margin, -31.5 / 2];
    }

    private size(): Point {
        const cssSize = this.element.getBoundingClientRect();
        return [cssSize.width, cssSize.height];
    }
}

function niceNumber(n: number) {
    return Math.round(n).toLocaleString('en-us').replace('-', '−');
}

/** The closest that the last point will be allowed to get to the edge. */
const EDGE_FRACTION = 0.125;

/**
 * Determines what the Y range of the chart should be.
 * @param ys The Y values of the chart.
 * @return The lowest and highest values of the range.
 */
function calculateYRange(ys: number[]): [number, number] {
    const yMax = Math.max(...ys);
    const yMin = Math.min(...ys);
    const yMid = (yMin + yMax) / 2;
    const lastY = ys[ys.length - 1];
    const yLo = yMid - Y_DEGREE_RANGE / 2;
    const yProportion = Math.max(
        Math.min((lastY - yLo) / Y_DEGREE_RANGE, 1 - EDGE_FRACTION),
        EDGE_FRACTION);
    const rangeLo = lastY - yProportion * Y_DEGREE_RANGE;
    return [rangeLo, rangeLo + Y_DEGREE_RANGE];
}

/**
 * Projects a Cartesian coordinate into Canvas space.
 *
 * @param coord The `[x, y]` coordinate to project.
 * @param size The `[width, height]` of the context.
 * @param xRange The range of X values in the context.
 * @param yRange The range of Y values in the context.
 * @return The `[x, y]` coordinate in Canvas space.
 */
function project(
    coord: Point,
    size: Point,
    xRange: [number, number],
    yRange: [number, number]): Point {
    const [x, y] = coord;
    const [xMin, xMax] = xRange;
    const xSpan = xMax - xMin;
    const [yMin, yMax] = yRange;
    const ySpan = yMax - yMin;
    const [xSize, ySize] = size;
    return [
        (x - xMin) / xSpan * xSize,
        (yMax - y) / ySpan * ySize,
    ]
}