changeset 19:47987502bf4c

Add graph, make it public, and bump the version. This checks in the tsc-compiled JS files because idklol.
author Paul Fisher <paul@pfish.zone>
date Sun, 13 Oct 2019 18:22:06 -0400
parents 9d07dc5c3340
children a7fe635d1c88
files MANIFEST.in setup.py weather_server/static/amd/mad.js weather_server/static/amd/mad.js.map weather_server/static/graph.js weather_server/static/graph.js.map weather_server/static/style.css weather_server/templates/location.html weather_server/typescript/app/graph.ts
diffstat 9 files changed, 298 insertions(+), 40 deletions(-) [+]
line wrap: on
line diff
--- a/MANIFEST.in	Sat Oct 12 21:02:02 2019 -0400
+++ b/MANIFEST.in	Sun Oct 13 18:22:06 2019 -0400
@@ -1,2 +1,2 @@
-include weather_server/static/*
+recursive-include weather_server/static/ *
 include weather_server/templates/*
--- a/setup.py	Sat Oct 12 21:02:02 2019 -0400
+++ b/setup.py	Sun Oct 13 18:22:06 2019 -0400
@@ -2,7 +2,7 @@
 
 setuptools.setup(
     name='weather-server',
-    version='0.0.3',
+    version='0.0.4',
     packages=setuptools.find_packages(),
     python_requires='>=3.7',
     install_requires=[
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/weather_server/static/amd/mad.js	Sun Oct 13 18:22:06 2019 -0400
@@ -0,0 +1,34 @@
+"use strict";
+class MADRegistry {
+    constructor() {
+        this.mods = new Map();
+    }
+    define(name, deps, factory) {
+        this.mods.set(name, { deps, factory, exports: null });
+    }
+    require(dep, srcMod) {
+        if (dep === 'require') {
+            return (child) => this.require(child, srcMod);
+        }
+        if (dep === 'exports') {
+            if (!srcMod)
+                throw new Error('Internal consistency error.');
+            return srcMod.exports;
+        }
+        const mod = this.mods.get(dep);
+        if (!mod)
+            throw new Error('Undefined module.');
+        if (mod.exports)
+            return mod.exports;
+        mod.exports = {};
+        const deps = mod.deps.map(child => this.require(child, mod));
+        mod.factory(...deps);
+        return mod.exports;
+    }
+    install(to) {
+        to['define'] =
+            (name, deps, factory) => this.define(name, deps, factory);
+        to['require'] = (dep) => this.require(dep);
+    }
+}
+//# sourceMappingURL=mad.js.map
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/weather_server/static/amd/mad.js.map	Sun Oct 13 18:22:06 2019 -0400
@@ -0,0 +1,1 @@
+{"version":3,"file":"mad.js","sourceRoot":"","sources":["../../typescript/amd/mad-amd.ts"],"names":[],"mappings":";AAyBA,MAAM,WAAW;IAAjB;QAEqB,SAAI,GAAG,IAAI,GAAG,EAAkB,CAAC;IAkEtD,CAAC;IApDG,MAAM,CAAC,IAAY,EAAE,IAAc,EAAE,OAAwB;QACzD,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,EAAC,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAC,CAAC,CAAC;IACxD,CAAC;IAeD,OAAO,CAAC,GAAW,EAAE,MAAe;QAChC,IAAI,GAAG,KAAK,SAAS,EAAE;YACnB,OAAO,CAAC,KAAa,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;SACzD;QACD,IAAI,GAAG,KAAK,SAAS,EAAE;YACnB,IAAI,CAAC,MAAM;gBAAE,MAAM,IAAI,KAAK,CAAC,6BAA6B,CAAC,CAAC;YAG5D,OAAO,MAAM,CAAC,OAAQ,CAAC;SAC1B;QACD,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAI,CAAC,GAAG;YAAE,MAAM,IAAI,KAAK,CAAC,mBAAmB,CAAC,CAAC;QAE/C,IAAI,GAAG,CAAC,OAAO;YAAE,OAAO,GAAG,CAAC,OAAO,CAAC;QAEpC,GAAG,CAAC,OAAO,GAAG,EAAE,CAAC;QACjB,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,CAAC;QAC7D,GAAG,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC;QACrB,OAAO,GAAG,CAAC,OAAO,CAAC;IACvB,CAAC;IAUD,OAAO,CAAC,EAAO;QACX,EAAE,CAAC,QAAQ,CAAC;YACR,CAAC,IAAY,EAAE,IAAc,EAAE,OAAwB,EAAE,EAAE,CACvD,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC;QACzC,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,GAAW,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IACvD,CAAC;CACJ","sourcesContent":["/** Type of the AMD factory function. */\ntype FactoryFunction = (...deps: unknown[]) => void;\n\n/** An individual AMD module. */\ninterface Module {\n    /** The names of the module's dependencies. */\n    deps: string[];\n    /**\n     * The function that, when called with the module's list of dependencies,\n     * creates the module.\n     */\n    factory: FactoryFunction;\n    /**\n     * When null, an indication that the module has not yet been reified.\n     * When non-null, the members that the module has exported.\n     */\n    exports: {}|null;\n}\n\n/**\n * Minimal AMD Dumb Registry: the dumbest possible implementation of AMD,\n * to handle only the code that `tsc -m amd` produces.\n *\n * Supports `require` of absolute paths and `define`s.\n */\nclass MADRegistry {\n    /** The registry itself, mapping from name to module. */\n    private readonly mods = new Map<string, Module>();\n\n    /**\n     * The (subset of) the AMD `define` function we implement.\n     *\n     * This supports\n     *\n     * - Absolute paths\n     * - `exports`-based module construction\n     *\n     * @param name The name of the module to define.\n     * @param deps The dependencies of the module. Must be explicit.\n     * @param factory The module's factory function.\n     */\n    define(name: string, deps: string[], factory: FactoryFunction) {\n        this.mods.set(name, {deps, factory, exports: null});\n    }\n\n    /**\n     * The (subset of) the AMD `require` function we implement.\n     * Only `require(dep)` is exposed to users; `require(dep, srcMod)`\n     * is internal-only.\n     *\n     * - Does not support relative paths.\n     * - Does not define `require.amd`, because we do not fully support AMD\n     *   and don't want to give the impression that we do.\n     *\n     * @param dep The name of the dependency.\n     * @param srcMod The module whence the dependency was requested.\n     *     Used for when the name `exports` is required.\n     */\n    require(dep: string, srcMod?: Module): {} {\n        if (dep === 'require') {\n            return (child: string) => this.require(child, srcMod);\n        }\n        if (dep === 'exports') {\n            if (!srcMod) throw new Error('Internal consistency error.');\n            // We know this is safe because a module can only ever require\n            // its own exports after it is itself required.\n            return srcMod.exports!;\n        }\n        const mod = this.mods.get(dep);\n        if (!mod) throw new Error('Undefined module.');\n        // If we've required the module before, return its exports.\n        if (mod.exports) return mod.exports;\n        // Otherwise, we need to prepare the module and require its parents.\n        mod.exports = {};\n        const deps = mod.deps.map(child => this.require(child, mod));\n        mod.factory(...deps);\n        return mod.exports;\n    }\n\n    /**\n     * Installs this registry into the given object, usually `window` or `self`.\n     * For usage with a separately-compiled JS file, do:\n     *\n     * ```typescript\n     * new MADRegistry().install();\n     * ```\n     */\n    install(to: any) {\n        to['define'] =\n            (name: string, deps: string[], factory: FactoryFunction) =>\n                this.define(name, deps, factory);\n        to['require'] = (dep: string) => this.require(dep);\n    }\n}\n"]}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/weather_server/static/graph.js	Sun Oct 13 18:22:06 2019 -0400
@@ -0,0 +1,192 @@
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+    return new (P || (P = Promise))(function (resolve, reject) {
+        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+        function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); }
+        step((generator = generator.apply(thisArg, _arguments || [])).next());
+    });
+};
+define("math", ["require", "exports"], function (require, exports) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+    /** Converts Celsius to Fahrenheit. */
+    function cToF(tempC) {
+        return tempC * 9 / 5 + 32;
+    }
+    exports.cToF = cToF;
+    const MAGNUS_B = 17.62;
+    const MAGNUS_C = 243.12;
+    /** The gamma function to calculate dew point. */
+    function gammaFn(tempC, rhPct) {
+        return Math.log(rhPct / 100) + MAGNUS_B * tempC / (MAGNUS_C + tempC);
+    }
+    /** Calculates the dew point. */
+    function dewPointC(tempC, rhPct) {
+        const gamma = gammaFn(tempC, rhPct);
+        return MAGNUS_C * gamma / (MAGNUS_B - gamma);
+    }
+    exports.dewPointC = dewPointC;
+});
+define("graph", ["require", "exports", "math"], function (require, exports, math_1) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+    /** 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.
+     */
+    function setUp(root, tempElement, dewPointElement) {
+        return __awaiter(this, void 0, void 0, function* () {
+            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 = yield fetch(query.href);
+            if (!results.ok)
+                return;
+            const data = yield 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, math_1.cToF(s.temp_c)]);
+            const dewPointsF = readings.map(s => [s.sample_time, math_1.cToF(math_1.dewPointC(s.temp_c, s.rh_pct))]);
+            setUpElement(tempElement, [startTS, nowTS], tempsF);
+            setUpElement(dewPointElement, [startTS, nowTS], dewPointsF);
+        });
+    }
+    exports.setUp = setUp;
+    /**
+     * 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, timeRange, data) {
+        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 {
+        /**
+         * 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(element, timeRange, data) {
+            this.element = element;
+            this.timeRange = timeRange;
+            this.data = data;
+            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 = [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);
+        }
+        redraw(ctx) {
+            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 yRange = calculateYRange(this.data.map(d => d[1]));
+            const [fullW, fullH] = this.size();
+            const [xPad, yMargin] = this.measureMargin(ctx);
+            const graphSize = [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);
+        }
+        measureMargin(ctx) {
+            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];
+        }
+        size() {
+            const cssSize = this.element.getBoundingClientRect();
+            return [cssSize.width, cssSize.height];
+        }
+    }
+    function niceNumber(n) {
+        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) {
+        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, size, xRange, yRange) {
+        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,
+        ];
+    }
+});
+//# sourceMappingURL=graph.js.map
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/weather_server/static/graph.js.map	Sun Oct 13 18:22:06 2019 -0400
@@ -0,0 +1,1 @@
+{"version":3,"file":"graph.js","sourceRoot":"","sources":["../typescript/app/math.ts","../typescript/app/graph.ts"],"names":[],"mappings":"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","sourcesContent":["/** Converts Celsius to Fahrenheit. */\nexport function cToF(tempC: number): number {\n    return tempC * 9 / 5 + 32;\n}\n\nconst MAGNUS_B = 17.62;\nconst MAGNUS_C = 243.12;\n\n/** The gamma function to calculate dew point. */\nfunction gammaFn(tempC: number, rhPct: number): number {\n    return Math.log(rhPct / 100) + MAGNUS_B * tempC / (MAGNUS_C + tempC);\n}\n\n/** Calculates the dew point. */\nexport function dewPointC(tempC: any, rhPct: any): number {\n    const gamma = gammaFn(tempC, rhPct);\n    return MAGNUS_C * gamma / (MAGNUS_B - gamma);\n}\n","import { WeatherResponse } from './apis';\nimport { cToF, dewPointC } from './math';\n\n/** The amount of time we will draw on the graph. */\nconst HISTORY_SECONDS = 60 * 60 * 4;\n/** The amount of buffer room we will request before HISTORY_SECONDS. */\nconst BUFFER_SECONDS = 300;\n\n/**\n * Sets up everything.\n * @param tempElement The element where temperature data is.\n * @param dewPointElement The element where the dew point is.\n */\nexport async function setUp(\n    root: HTMLElement,\n    tempElement: HTMLElement,\n    dewPointElement: HTMLElement) {\n    const nowTS = new Date().getTime() / 1000;\n    const startTS = nowTS - HISTORY_SECONDS;\n    const query = new URL('?', location.href);\n    query.pathname = query.pathname + '/recent';\n    query.searchParams.set('seconds', String(HISTORY_SECONDS + BUFFER_SECONDS));\n    const results = await fetch(query.href);\n    if (!results.ok) return;\n    const data: WeatherResponse = await results.json();\n    const readings = data.readings;\n    if (readings.length === 0) return;\n    root.classList.remove('plain');\n    root.classList.add('fancy');\n\n    const tempsF =\n        readings.map(s => [s.sample_time, cToF(s.temp_c)] as Point);\n    const dewPointsF = readings.map(\n        s => [s.sample_time, cToF(dewPointC(s.temp_c, s.rh_pct))] as Point);\n    setUpElement(tempElement, [startTS, nowTS], tempsF);\n    setUpElement(dewPointElement, [startTS, nowTS], dewPointsF);\n}\n\ntype Point = [number, number];\n\n/**\n * Sets up charting for this element.\n * @param element The element to put a graph in.\n * @param timeRange The `[start, end]` of the time range.\n * @param data The data to chart.\n */\nfunction setUpElement(\n    element: HTMLElement, timeRange: [number, number], data: Point[]) {\n    if (data.length === 0) return;\n    const chart = new Chart(element, timeRange, data);\n    chart.resize();\n    addEventListener('resize', () => chart.resize());\n}\n\n/** The number of degrees that the graph shows vertically. */\nconst Y_DEGREE_RANGE = 10;\n\nconst LINE_WIDTH_PX = 2;\n\nconst FONT_SIZE = '40px';\n\nclass Chart {\n    private readonly canvas: HTMLCanvasElement;\n    private readonly unit: string;\n    /**\n     * Creates a new chart.\n     * @param element The parent element to create `<canvas>`-based chart in.\n     * @param timeRange `[start, end]` of the range to chart as Unix timestamps.\n     * @param data The data to chart.\n     */\n    constructor(\n        private readonly element: HTMLElement,\n        private readonly timeRange: [number, number],\n        private readonly data: Point[]) {\n        this.canvas = document.createElement('canvas');\n        this.element.insertBefore(this.canvas, element.firstChild);\n        const unit = element.getElementsByClassName('unit')[0];\n        this.unit = unit && unit.textContent || '';\n    }\n\n    resize() {\n        const dpr = self.devicePixelRatio || 1;\n        const [w, h] = this.size();\n        const pxSize: Point = [w * dpr, h * dpr];\n        this.canvas.width = pxSize[0];\n        this.canvas.height = pxSize[1];\n        const ctx = this.canvas.getContext('2d')!;\n        ctx.clearRect(0, 0, pxSize[0], pxSize[1]);\n        ctx.scale(dpr, dpr);\n        this.redraw(ctx);\n    }\n\n    private redraw(ctx: CanvasRenderingContext2D) {\n        const stroke = getComputedStyle(this.element).color!;\n        const family = getComputedStyle(this.element).fontFamily!;\n        ctx.strokeStyle = stroke;\n        ctx.lineJoin = 'round';\n        ctx.lineWidth = LINE_WIDTH_PX;\n        ctx.font = `bold ${FONT_SIZE} ${family}`;\n\n        const yRange = calculateYRange(this.data.map(d => d[1]));\n\n        const [fullW, fullH] = this.size();\n        const [xPad, yMargin] = this.measureMargin(ctx);\n        const graphSize: Point = [fullW - xPad, fullH];\n\n        ctx.beginPath();\n        for (const pt of this.data) {\n            const projected = project(pt, graphSize, this.timeRange, yRange);\n            ctx.lineTo(...projected);\n        }\n        ctx.stroke();\n        ctx.beginPath();\n        const lastPt = this.data[this.data.length - 1];\n        const center = project(lastPt, graphSize, this.timeRange, yRange);\n        ctx.ellipse(\n            center[0],\n            center[1],\n            1.5 * LINE_WIDTH_PX,\n            1.5 * LINE_WIDTH_PX,\n            0,\n            0,\n            2 * Math.PI);\n        ctx.fillStyle = getComputedStyle(this.element).backgroundColor!;\n        ctx.fill();\n        ctx.stroke();\n\n        ctx.fillStyle = stroke;\n        ctx.textAlign = 'left';\n        ctx.textBaseline = 'top';\n        ctx.fillText(\n            `${niceNumber(lastPt[1])} ${this.unit}`,\n            center[0] + 5 * LINE_WIDTH_PX,\n            center[1] + yMargin);\n    }\n\n    private measureMargin(ctx: CanvasRenderingContext2D): [number, number] {\n        const bbox = ctx.measureText(`−99 ${this.unit}`);\n        const margin =\n            5 * LINE_WIDTH_PX +  // margin to text\n            bbox.width +  // max (?) width of text\n            16;  // Pixel margin to wall.\n        return [margin, -31.5 / 2];\n    }\n\n    private size(): Point {\n        const cssSize = this.element.getBoundingClientRect();\n        return [cssSize.width, cssSize.height];\n    }\n}\n\nfunction niceNumber(n: number) {\n    return Math.round(n).toLocaleString('en-us').replace('-', '−');\n}\n\n/** The closest that the last point will be allowed to get to the edge. */\nconst EDGE_FRACTION = 0.125;\n\n/**\n * Determines what the Y range of the chart should be.\n * @param ys The Y values of the chart.\n * @return The lowest and highest values of the range.\n */\nfunction calculateYRange(ys: number[]): [number, number] {\n    const yMax = Math.max(...ys);\n    const yMin = Math.min(...ys);\n    const yMid = (yMin + yMax) / 2;\n    const lastY = ys[ys.length - 1];\n    const yLo = yMid - Y_DEGREE_RANGE / 2;\n    const yProportion = Math.max(\n        Math.min((lastY - yLo) / Y_DEGREE_RANGE, 1 - EDGE_FRACTION),\n        EDGE_FRACTION);\n    const rangeLo = lastY - yProportion * Y_DEGREE_RANGE;\n    return [rangeLo, rangeLo + Y_DEGREE_RANGE];\n}\n\n/**\n * Projects a Cartesian coordinate into Canvas space.\n *\n * @param coord The `[x, y]` coordinate to project.\n * @param size The `[width, height]` of the context.\n * @param xRange The range of X values in the context.\n * @param yRange The range of Y values in the context.\n * @return The `[x, y]` coordinate in Canvas space.\n */\nfunction project(\n    coord: Point,\n    size: Point,\n    xRange: [number, number],\n    yRange: [number, number]): Point {\n    const [x, y] = coord;\n    const [xMin, xMax] = xRange;\n    const xSpan = xMax - xMin;\n    const [yMin, yMax] = yRange;\n    const ySpan = yMax - yMin;\n    const [xSize, ySize] = size;\n    return [\n        (x - xMin) / xSpan * xSize,\n        (yMax - y) / ySpan * ySize,\n    ]\n}\n"]}
\ No newline at end of file
--- a/weather_server/static/style.css	Sat Oct 12 21:02:02 2019 -0400
+++ b/weather_server/static/style.css	Sun Oct 13 18:22:06 2019 -0400
@@ -47,9 +47,10 @@
     padding: 0;
     font: inherit;
     flex: auto;
+    line-height: 1;
 }
 
-p.plain {
+.plain p {
     box-sizing: border-box;
     flex: 1;
     position: relative;
@@ -57,35 +58,35 @@
     flex-direction: column;
     align-items: baseline;
     padding: 24px;
-    line-height: 1;
 }
 
-p.plain.important .key {
+.plain p.important .key {
     display: block;
     opacity: 75%;
     font-size: 18px;
 }
 
-p.plain.important .value {
+.plain p.important .value {
     margin-top: 0;
     font-size: 150px;
     display: flex;
     flex-flow: row nowrap;
 }
 
-p.plain.important .value .unit {
+.plain p.important .value .unit {
     font-size: 33%;
     font-weight: bold;
     opacity: 66%;
     padding-top: 0.25em;
 }
 
-p.fancy {
+.fancy p.important {
     position: relative;
     flex: 1;
+    height: 200px;
 }
 
-p.fancy canvas {
+.fancy p.important canvas {
     display: block;
     position: absolute;
     top: 0;
@@ -96,6 +97,23 @@
     height: 100%;
 }
 
+.fancy p.important .key {
+    display: block;
+    position: absolute;
+    top: 24px;
+    left: 24px;
+    margin: -4px;
+    padding: 4px;
+    background-color: inherit;
+
+    font-size: 18px;
+    opacity: 75%;
+}
+
+.fancy p.important .value {
+    display: none;
+}
+
 #temp {
     background: var(--temp-background);
     color: var(--temp-text);
--- a/weather_server/templates/location.html	Sat Oct 12 21:02:02 2019 -0400
+++ b/weather_server/templates/location.html	Sun Oct 13 18:22:06 2019 -0400
@@ -7,10 +7,10 @@
             rel="stylesheet"
             href="{{ url_for('static', filename='style.css') }}">
     </head>
-    <body>
+    <body class="plain">
         <h1><span>{{ location.name }} conditions</span></h1>
         {% if last_reading %}
-            <p class="plain important" id="temp">
+            <p class="important" id="temp">
                 <span class="key">Temperature</span>
                 <span class="value">
                     <span class="pad"> </span>
@@ -20,7 +20,7 @@
                     <span class="unit">&deg;F</span>
                 </span>
             </p>
-            <p class="plain important" id="dewpoint">
+            <p class="important" id="dewpoint">
                 <span class="key">Dew point</span>
                 <span class="value">
                     <span class="pad"> </span>
@@ -39,15 +39,14 @@
         {% else %}
             <p id="big-question-mark">?</p>
         {% endif %}
-        <!--
-            <script src="{{ url_for('static', filename='amd/mad.js') }}"></script>
-            <script>new MADRegistry().install(self);</script>
-            <script src="{{ url_for('static', filename='graph.js') }}"></script>
-            <script>
-                require('graph').setUp(
-                    document.getElementById('temp'),
-                    document.getElementById('dewpoint'));
-            </script>
-        <!-- -->
+        <script src="{{ url_for('static', filename='amd/mad.js') }}"></script>
+        <script>new MADRegistry().install(self);</script>
+        <script src="{{ url_for('static', filename='graph.js') }}"></script>
+        <script>
+            require('graph').setUp(
+                document.body,
+                document.getElementById('temp'),
+                document.getElementById('dewpoint'));
+        </script>
     </body>
 </html>
--- a/weather_server/typescript/app/graph.ts	Sat Oct 12 21:02:02 2019 -0400
+++ b/weather_server/typescript/app/graph.ts	Sun Oct 13 18:22:06 2019 -0400
@@ -12,7 +12,9 @@
  * @param dewPointElement The element where the dew point is.
  */
 export async function setUp(
-    tempElement: HTMLElement, dewPointElement: HTMLElement) {
+    root: HTMLElement,
+    tempElement: HTMLElement,
+    dewPointElement: HTMLElement) {
     const nowTS = new Date().getTime() / 1000;
     const startTS = nowTS - HISTORY_SECONDS;
     const query = new URL('?', location.href);
@@ -23,6 +25,8 @@
     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);
@@ -44,22 +48,20 @@
     element: HTMLElement, timeRange: [number, number], data: Point[]) {
     if (data.length === 0) return;
     const chart = new Chart(element, timeRange, data);
-    requestAnimationFrame(() => {
-        chart.resize();
-        addEventListener('resize', () => chart.resize());
-    });
+    chart.resize();
+    addEventListener('resize', () => chart.resize());
 }
 
 /** The number of degrees that the graph shows vertically. */
 const Y_DEGREE_RANGE = 10;
 
-/** The width of the space on the right side of the graph. */
-const X_PADDING_PX = 80;
+const LINE_WIDTH_PX = 2;
 
-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.
@@ -70,10 +72,10 @@
         private readonly element: HTMLElement,
         private readonly timeRange: [number, number],
         private readonly data: Point[]) {
-        this.element.classList.remove('plain');
-        this.element.classList.add('fancy');
         this.canvas = document.createElement('canvas');
         this.element.insertBefore(this.canvas, element.firstChild);
+        const unit = element.getElementsByClassName('unit')[0];
+        this.unit = unit && unit.textContent || '';
     }
 
     resize() {
@@ -90,14 +92,17 @@
 
     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 yRange = calculateYRange(this.data.map(d => d[1]));
 
         const [fullW, fullH] = this.size();
-        const graphSize: Point = [fullW - X_PADDING_PX, fullH];
+        const [xPad, yMargin] = this.measureMargin(ctx);
+        const graphSize: Point = [fullW - xPad, fullH];
 
         ctx.beginPath();
         for (const pt of this.data) {
@@ -120,14 +125,22 @@
         ctx.fill();
         ctx.stroke();
 
-        const family = getComputedStyle(this.element).fontFamily!;
+        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);
+    }
 
-        ctx.fillStyle = stroke;
-        ctx.textBaseline = 'middle';
-        ctx.textAlign = 'left';
-        ctx.font = `bold 30px ${family}`;
-        ctx.fillText(
-            niceNumber(lastPt[1]), center[0] + 5 * LINE_WIDTH_PX, center[1]);
+    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 {