Mercurial > personal > weather-server
view weather_server/typescript/app/graph.ts @ 18:9d07dc5c3340
Make graph look nicer.
author | Paul Fisher <paul@pfish.zone> |
---|---|
date | Sat, 12 Oct 2019 21:02:02 -0400 |
parents | fdb874e0b270 |
children | 47987502bf4c |
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( 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; 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); requestAnimationFrame(() => { 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; class Chart { private readonly canvas: HTMLCanvasElement; /** * 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.element.classList.remove('plain'); this.element.classList.add('fancy'); this.canvas = document.createElement('canvas'); this.element.insertBefore(this.canvas, element.firstChild); } 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!; ctx.strokeStyle = stroke; ctx.lineJoin = 'round'; ctx.lineWidth = LINE_WIDTH_PX; const yRange = calculateYRange(this.data.map(d => d[1])); const [fullW, fullH] = this.size(); const graphSize: Point = [fullW - X_PADDING_PX, 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(); const family = getComputedStyle(this.element).fontFamily!; 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 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, ] }