Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
187 lines
14 KiB
HTML
187 lines
14 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Thermograph: compare comfort</title>
|
|
<meta name="description" content="Compare how well two or more places hit your comfort temperature over any date range, side by side, each graded against its own ~45-year climate history." />
|
|
<meta name="theme-color" content="#f0803c" />
|
|
<!-- iOS/Android home-screen install: run standalone (no browser chrome) when
|
|
added to the Home Screen. On iOS 16.4+ this is what unlocks Web Push. -->
|
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
<meta name="mobile-web-app-capable" content="yes" />
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
<meta name="apple-mobile-web-app-title" content="Thermograph" />
|
|
<!-- Link previews (Discord, Slack, iMessage…). The server fills the og:url /
|
|
og:image origins in from this request's scheme://host + base path —
|
|
crawlers need absolute URLs and don't run JS. -->
|
|
<meta property="og:type" content="website" />
|
|
<meta property="og:site_name" content="Thermograph" />
|
|
<meta property="og:title" content="Thermograph: compare comfort" />
|
|
<meta property="og:description" content="Compare how well two or more places hit your comfort temperature over any date range, side by side, each graded against its own ~45-year climate history." />
|
|
<meta property="og:url" content="__ORIGIN__/compare" />
|
|
<meta property="og:image" content="__ORIGIN__/logo.png?v=3" />
|
|
<meta property="og:image:width" content="512" />
|
|
<meta property="og:image:height" content="512" />
|
|
<meta property="og:image:alt" content="Thermograph logo" />
|
|
<meta name="twitter:card" content="summary" />
|
|
<link rel="canonical" href="__ORIGIN__/compare" />
|
|
<link rel="icon" href="favicon.svg?v=3" type="image/svg+xml" />
|
|
<link rel="icon" href="favicon-48.png?v=3" type="image/png" sizes="48x48" />
|
|
<link rel="icon" href="favicon-32.png?v=3" type="image/png" sizes="32x32" />
|
|
<link rel="icon" href="favicon-16.png?v=3" type="image/png" sizes="16x16" />
|
|
<link rel="apple-touch-icon" href="apple-touch-icon.png?v=3" />
|
|
<link rel="manifest" href="manifest.webmanifest" />
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<link rel="stylesheet" href="style.css" />
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div class="brand">
|
|
<div>
|
|
<h1><a href="./"><span class="logo"><svg viewBox="0 0 512 512" width="28" height="28" aria-hidden="true"><rect x="32" y="32" width="448" height="448" rx="96" fill="#10141B" stroke="#FFFFFF" stroke-opacity="0.08" stroke-width="4"/><rect x="115" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="163" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="211" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="259" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="307" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="355" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="403" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="163" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="211" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="259" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="307" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="355" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="403" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="67" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="163" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="211" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="259" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="307" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="355" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="403" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="67" y="259" width="42" height="42" rx="10" fill="#131C1F"/><rect x="211" y="259" width="42" height="42" rx="10" fill="#131C1F"/><rect x="403" y="259" width="42" height="42" rx="10" fill="#131C1F"/><rect x="67" y="211" width="42" height="42" rx="10" fill="#1E1E22"/><rect x="115" y="211" width="42" height="42" rx="10" fill="#1E1E22"/><rect x="211" y="211" width="42" height="42" rx="10" fill="#1E1E22"/><rect x="307" y="211" width="42" height="42" rx="10" fill="#1E1E22"/><rect x="67" y="163" width="42" height="42" rx="10" fill="#1D1C1E"/><rect x="115" y="163" width="42" height="42" rx="10" fill="#1D1C1E"/><rect x="307" y="163" width="42" height="42" rx="10" fill="#1D1C1E"/><rect x="355" y="163" width="42" height="42" rx="10" fill="#1D1C1E"/><rect x="67" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="115" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="163" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="211" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="259" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="307" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="355" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="67" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="115" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="163" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="211" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="259" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="307" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="355" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="67" y="403" width="42" height="42" rx="10" fill="#2166ac"/><rect x="67" y="355" width="42" height="42" rx="10" fill="#4393c3"/><rect x="115" y="355" width="42" height="42" rx="10" fill="#4393c3"/><rect x="115" y="307" width="42" height="42" rx="10" fill="#92c5de"/><rect x="115" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="163" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="163" y="211" width="42" height="42" rx="10" fill="#f6c18a"/><rect x="163" y="163" width="42" height="42" rx="10" fill="#ef9351"/><rect x="211" y="163" width="42" height="42" rx="10" fill="#ef9351"/><rect x="259" y="163" width="42" height="42" rx="10" fill="#ef9351"/><rect x="259" y="211" width="42" height="42" rx="10" fill="#f6c18a"/><rect x="259" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="307" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="355" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="355" y="211" width="42" height="42" rx="10" fill="#f6c18a"/><rect x="403" y="211" width="42" height="42" rx="10" fill="#f6c18a"/><rect x="403" y="163" width="42" height="42" rx="10" fill="#ef9351"/><rect x="403" y="115" width="42" height="42" rx="10" fill="#dd6b52"/><rect x="403" y="67" width="42" height="42" rx="10" fill="#8f0e20"/></svg></span>Thermograph</a></h1>
|
|
<p class="tag">Which place best hits your comfort temperature over a date range</p>
|
|
</div>
|
|
<details class="nav-menu">
|
|
<summary class="nav-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg></summary>
|
|
<div class="nav-panel">
|
|
<nav class="view-nav" aria-label="Views">
|
|
<a href="./" data-view="map">Weekly</a>
|
|
<a href="calendar" data-view="calendar">Calendar</a>
|
|
<a href="day" data-view="day">Day Detail</a>
|
|
<a href="compare" data-view="compare" class="active">Compare</a>
|
|
<a href="score" data-view="score">Score</a>
|
|
<a href="climate">Climate</a>
|
|
<a href="alerts">Alerts</a>
|
|
</nav>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</header>
|
|
|
|
<main>
|
|
<!-- Locations to line up against each other. -->
|
|
<section class="controls">
|
|
<div class="cmp-locs">
|
|
<span class="cal-filter-label">Locations</span>
|
|
<ul id="cmp-loc-list" class="cmp-loc-list"></ul>
|
|
<div class="cmp-loc-actions">
|
|
<button type="button" id="cmp-add" class="find-btn"></button>
|
|
<button type="button" id="cmp-refresh" class="cmp-refresh-btn" hidden>Refresh comparison ↻</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- The comfort target, what temperature to judge by, the tolerance band, and
|
|
the date range. Comfort/band/basis recompute instantly (no refetch); only
|
|
the date range or the location set trigger a data load. -->
|
|
<!-- Comfort/tolerance target — the primary knob, kept inline above the results
|
|
(never inside the mobile filter sheet). Recomputes the ranking instantly. -->
|
|
<div id="cmp-comfort" class="cmp-comfort-wrap" hidden>
|
|
<div class="cmp-range-labels">
|
|
<span class="cmp-rl cmp-rl-comfort">Comfort <b id="cmp-comfort-val"></b></span>
|
|
<span class="cmp-rl cmp-rl-tol">Tolerance <b id="cmp-tol-val"></b></span>
|
|
</div>
|
|
<div class="cmp-ticks" id="cmp-ticks" aria-hidden="true">
|
|
<span class="cmp-tick tol" id="cmp-tick-tlo"></span>
|
|
<span class="cmp-tick" id="cmp-tick-lo"></span>
|
|
<span class="cmp-tick" id="cmp-tick-hi"></span>
|
|
<span class="cmp-tick tol" id="cmp-tick-thi"></span>
|
|
</div>
|
|
<div class="dual-range quad-range" id="cmp-slicer">
|
|
<div class="dual-track">
|
|
<span class="dual-fill dual-fill-tol" id="cmp-fill-tol"></span>
|
|
<span class="dual-fill" id="cmp-fill"></span>
|
|
</div>
|
|
<input id="cmp-tlo" type="range" min="30" max="100" step="1" class="thumb-tol" aria-label="Tolerance range lower bound" />
|
|
<input id="cmp-lo" type="range" min="30" max="100" step="1" aria-label="Comfort range lower bound" />
|
|
<input id="cmp-hi" type="range" min="30" max="100" step="1" aria-label="Comfort range upper bound" />
|
|
<input id="cmp-thi" type="range" min="30" max="100" step="1" class="thumb-tol" aria-label="Tolerance range upper bound" />
|
|
</div>
|
|
<span class="hint">Inner handles set your comfort range; outer handles set the tolerance range. Days in tolerance still count toward the score, scaled by how far past comfort they fall.</span>
|
|
<!-- Which temperature to judge each day by — a scoring control, so it sits with
|
|
the comfort slider above the results, not in the filter sheet. -->
|
|
<div class="cmp-basis-block">
|
|
<span class="cal-filter-label">Judge each day by its</span>
|
|
<div class="metric-toggle" id="cmp-basis" role="group" aria-label="Temperature to compare">
|
|
<button type="button" data-basis="tmax" class="active">High</button>
|
|
<button type="button" data-basis="mean">Mean</button>
|
|
<button type="button" data-basis="tmin">Low</button>
|
|
<button type="button" data-basis="feels">Feels</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="cmp-params" class="cmp-params" hidden>
|
|
<div class="cmp-param cmp-season" id="cmp-season">
|
|
<span class="cal-filter-label">Time of year</span>
|
|
<div class="cmp-season-dd" id="cmp-season-dd"></div>
|
|
<span class="hint">Count only days in these seasons/months; re-ranks instantly.</span>
|
|
</div>
|
|
|
|
<div class="cmp-param cmp-range cal-range" id="cmp-range">
|
|
<div class="cal-range-head">
|
|
<span class="cal-filter-label">Date range</span>
|
|
<span class="hint">any span; longer loads take a moment</span>
|
|
</div>
|
|
<label>From <input id="cmp-start" type="month" /></label>
|
|
<label>To <input id="cmp-end" type="month" /></label>
|
|
<span class="hint">Editing the range arms Load; tap it to reload.</span>
|
|
<button type="button" id="cmp-refresh-sheet" class="cmp-refresh-btn" hidden>Load / Refresh ↻</button>
|
|
</div>
|
|
|
|
<!-- A synced copy of the "Distribution by" selector (below), for the phone
|
|
sheet; buttons are cloned from the inline one in JS. Hidden on desktop. -->
|
|
<div class="cmp-param cmp-dist-metric-block">
|
|
<span class="cal-filter-label">Distribution by</span>
|
|
<div class="metric-toggle" id="cmp-dist-metric-sheet" role="group" aria-label="Distribution metric (in filters)"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="cmp-head" class="cal-head" hidden></div>
|
|
|
|
<div class="placeholder" id="cmp-placeholder">
|
|
<p>Add two or more places to see which one best matches your comfort temperature
|
|
across a date range: the share of days that land in your comfort band, how
|
|
often it runs colder or warmer, and by how much.</p>
|
|
</div>
|
|
|
|
<!-- Baseline: every place's typical temperature on one shared axis, so their
|
|
baselines line up against each other and the shaded comfort range. -->
|
|
<section id="cmp-baseline" class="cmp-baseline" hidden></section>
|
|
|
|
<div id="cmp-results" class="cmp-results"></div>
|
|
|
|
<!-- Climate distribution, independent of the comfort filter: how each place's
|
|
days spread across a metric's Record-Low→Record-High categories, mirroring
|
|
the calendar's totals strip. Its own metric + share/count toggles. -->
|
|
<section id="cmp-dist" class="cmp-dist" hidden>
|
|
<div class="ct-head">
|
|
<div class="cmp-dist-controls">
|
|
<span class="cal-filter-label">Distribution by</span>
|
|
<div class="metric-toggle" id="cmp-dist-metric" role="group" aria-label="Distribution metric">
|
|
<button type="button" data-metric="tmax" class="active">High</button>
|
|
<button type="button" data-metric="tmin">Low</button>
|
|
<button type="button" data-metric="feels">Feels</button>
|
|
<button type="button" data-metric="humid">Humid</button>
|
|
<button type="button" data-metric="wind">Wind</button>
|
|
<button type="button" data-metric="gust">Gust</button>
|
|
<button type="button" data-metric="precip">Precip</button>
|
|
<button type="button" data-metric="dsr">Dry streak</button>
|
|
</div>
|
|
</div>
|
|
<label class="ct-count-toggle"><input type="checkbox" id="cmp-dist-count" /> Show count</label>
|
|
</div>
|
|
<div id="cmp-dist-body" class="cmp-dist-body"></div>
|
|
</section>
|
|
</main>
|
|
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<!-- Product-event beacon. Inert unless the server flagged this page
|
|
on the root element; see frontend/static/track.js. -->
|
|
<script type="module" src="consent.js"></script>
|
|
<script type="module" src="track.js"></script>
|
|
<script type="module" src="compare.js"></script>
|
|
<script type="module" src="ios-install.js"></script>
|
|
</body>
|
|
</html>
|