Remove em-dashes from site copy and tighten the prose (#206)

Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.

Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.

Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
This commit is contained in:
Emi Griffith 2026-07-19 18:48:33 -07:00 committed by GitHub
parent ee12ae38e4
commit 8588051141
15 changed files with 57 additions and 57 deletions

View file

@ -101,10 +101,10 @@ heroLocate?.addEventListener("click", () => {
// like the button did nothing. // like the button did nothing.
locateMsg( locateMsg(
err.code === err.PERMISSION_DENIED err.code === err.PERMISSION_DENIED
? "Location permission was declined — pick a spot on the map instead." ? "Location permission was declined. Pick a spot on the map instead."
: err.code === err.TIMEOUT : err.code === err.TIMEOUT
? "That took too long. Try again, or pick a spot on the map." ? "That took too long. Try again, or pick a spot on the map."
: "Your location isn't available right now — pick a spot on the map instead.", : "Your location isn't available right now. Pick a spot on the map instead.",
); );
}, },
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 600000 }, { enableHighAccuracy: false, timeout: 10000, maximumAge: 600000 },
@ -255,7 +255,7 @@ function render(data) {
<span>Check historical data</span> <span>Check historical data</span>
<span class="cta-cal-arrow" aria-hidden="true"></span> <span class="cta-cal-arrow" aria-hidden="true"></span>
</a> </a>
<div class="section-title">${data.target_date} vs a typical day tap a metric for the full breakdown</div> <div class="section-title">${data.target_date} vs a typical day. Tap a metric for the full breakdown</div>
<div class="normals"> <div class="normals">
${cmpCard("High", "tmax", c.tmax, fmtTemp)} ${cmpCard("High", "tmax", c.tmax, fmtTemp)}
${cmpCard("Low", "tmin", c.tmin, fmtTemp)} ${cmpCard("Low", "tmin", c.tmin, fmtTemp)}
@ -366,7 +366,7 @@ function renderSeries() {
<div class="section-title">Trend vs normal${hasFc ? " · recent → forecast" : ""}</div> <div class="section-title">Trend vs normal${hasFc ? " · recent → forecast" : ""}</div>
<div id="chart-wrap"></div> <div id="chart-wrap"></div>
${tierKeyHtml()} ${tierKeyHtml()}
<div class="section-title">Daily, graded${hasFc ? " recent &amp; 7-day forecast" : ""}</div> <div class="section-title">Daily, graded${hasFc ? ": recent &amp; 7-day forecast" : ""}</div>
<div class="rd-orient"><span> Older</span><span>Newer </span></div> <div class="rd-orient"><span> Older</span><span>Newer </span></div>
${daysTable([...days].reverse(), target) || '<p class="muted">Nothing to grade.</p>'} ${daysTable([...days].reverse(), target) || '<p class="muted">Nothing to grade.</p>'}
`; `;
@ -527,7 +527,7 @@ function exportTableSvg(C) {
}); });
const markup = ` const markup = `
<text x="${PL}" y="${top + 8}" font-size="13" font-weight="700" fill="${C.ink}">Recent days, graded ${esc(placeLabel(lastGraded))}</text> <text x="${PL}" y="${top + 8}" font-size="13" font-weight="700" fill="${C.ink}">Recent days, graded: ${esc(placeLabel(lastGraded))}</text>
${th(cDate, "DATE")}${th(cPcp, "PRECIP")}${th(cHigh, "HIGH")}${th(cLow, "LOW")} ${th(cDate, "DATE")}${th(cPcp, "PRECIP")}${th(cHigh, "HIGH")}${th(cLow, "LOW")}
<line x1="${PL}" y1="${top + headH - 9}" x2="${W - PR}" y2="${top + headH - 9}" stroke="${C.grid}" stroke-width="1.5"/> <line x1="${PL}" y1="${top + headH - 9}" x2="${W - PR}" y2="${top + headH - 9}" stroke="${C.grid}" stroke-width="1.5"/>
${body}`; ${body}`;

View file

@ -3,8 +3,8 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Thermograph 2-year calendar</title> <title>Thermograph: 2-year calendar</title>
<meta name="description" content="A two-year calendar of daily weather grades for any location — see at a glance which days were unusually hot, cold, wet, or normal versus ~45 years of local climate history." /> <meta name="description" content="A two-year calendar of daily weather grades for any location. See at a glance which days were unusually hot, cold, wet, or normal versus ~45 years of local climate history." />
<meta name="theme-color" content="#f0803c" /> <meta name="theme-color" content="#f0803c" />
<!-- iOS/Android home-screen install: run standalone (no browser chrome) when <!-- 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. --> added to the Home Screen. On iOS 16.4+ this is what unlocks Web Push. -->
@ -17,8 +17,8 @@
crawlers need absolute URLs and don't run JS. --> crawlers need absolute URLs and don't run JS. -->
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="Thermograph" /> <meta property="og:site_name" content="Thermograph" />
<meta property="og:title" content="Thermograph 2-year calendar" /> <meta property="og:title" content="Thermograph: 2-year calendar" />
<meta property="og:description" content="A two-year calendar of daily weather grades for any location — see at a glance which days were unusually hot, cold, wet, or normal versus ~45 years of local climate history." /> <meta property="og:description" content="A two-year calendar of daily weather grades for any location. See at a glance which days were unusually hot, cold, wet, or normal versus ~45 years of local climate history." />
<meta property="og:url" content="__ORIGIN__/calendar" /> <meta property="og:url" content="__ORIGIN__/calendar" />
<meta property="og:image" content="__ORIGIN__/logo.png?v=3" /> <meta property="og:image" content="__ORIGIN__/logo.png?v=3" />
<meta property="og:image:width" content="512" /> <meta property="og:image:width" content="512" />
@ -108,7 +108,7 @@
<div id="cal-totals" class="cal-totals" hidden></div> <div id="cal-totals" class="cal-totals" hidden></div>
<div class="placeholder" id="cal-placeholder"> <div class="placeholder" id="cal-placeholder">
<p>Search a place — or open this from a graded location on the map — to see how <p>Search a place, or open this from a graded location on the map, to see how
every day of the last two years graded against that spot's own climate history.</p> every day of the last two years graded against that spot's own climate history.</p>
</div> </div>
<div id="calendar" class="calendar"></div> <div id="calendar" class="calendar"></div>

View file

@ -255,7 +255,7 @@ const weatherDropdown = () => `
<span class="cal-dd-caret" aria-hidden="true"></span> <span class="cal-dd-caret" aria-hidden="true"></span>
</summary> </summary>
<div class="cal-dd-panel"> <div class="cal-dd-panel">
<div class="cal-dd-group">Day feel from the daily high</div> <div class="cal-dd-group">Day feel, from the daily high</div>
${FEEL_WORDS.map(([val, lab]) => ${FEEL_WORDS.map(([val, lab]) =>
`<label class="cal-dd-opt"><input type="checkbox" data-wfeel="${val}"${checkedFeels.has(val) ? " checked" : ""}>${lab}</label>`).join("")} `<label class="cal-dd-opt"><input type="checkbox" data-wfeel="${val}"${checkedFeels.has(val) ? " checked" : ""}>${lab}</label>`).join("")}
<div class="cal-dd-group">Sky</div> <div class="cal-dd-group">Sky</div>
@ -561,8 +561,8 @@ function renderTotals(visible) {
const total = buckets.reduce((n, b) => n + b.n, 0); const total = buckets.reduce((n, b) => n + b.n, 0);
const head = wActive const head = wActive
? `${days.length.toLocaleString()} of ${visible.length.toLocaleString()} shown days match the weather filter by ${title}:` ? `${days.length.toLocaleString()} of ${visible.length.toLocaleString()} shown days match the weather filter, by ${title}:`
: `${visible.length.toLocaleString()} days shown by ${title}:`; : `${visible.length.toLocaleString()} days shown, by ${title}:`;
// The strip prints each category's share by default, or the raw day count when the // The strip prints each category's share by default, or the raw day count when the
// "Show count" checkbox (state: showCount) is ticked. // "Show count" checkbox (state: showCount) is ticked.
const countToggle = const countToggle =
@ -583,7 +583,7 @@ function renderKey() {
const segs = steps.map(([lab, d]) => const segs = steps.map(([lab, d]) =>
`<div class="tk-seg"><span class="tk-swatch" style="background:${drynessColor(d)}"></span> `<div class="tk-seg"><span class="tk-swatch" style="background:${drynessColor(d)}"></span>
<span class="tk-label">${lab}</span></div>`).join(""); <span class="tk-label">${lab}</span></div>`).join("");
keyEl.innerHTML = `<div class="section-title">Days since last rain dry streak (caps at ${DRY_CAP} days)</div> keyEl.innerHTML = `<div class="section-title">Days since last rain: dry streak (caps at ${DRY_CAP} days)</div>
<div class="tier-key">${segs}</div>${GUIDE_LINK}`; <div class="tier-key">${segs}</div>${GUIDE_LINK}`;
return; return;
} }

View file

@ -196,8 +196,8 @@ export function tempChart(key, days, n, xFor, C) {
legend: legend:
`<span><i style="background:${s.color}"></i>${s.label} (dashed = median)</span>` + `<span><i style="background:${s.color}"></i>${s.label} (dashed = median)</span>` +
`<span><i class="swatch-band" style="background:${hexA(TIER_COLORS.normal, 0.5)};border-color:${TIER_COLORS.normal}"></i>Band shaded by grade tier</span>` + `<span><i class="swatch-band" style="background:${hexA(TIER_COLORS.normal, 0.5)};border-color:${TIER_COLORS.normal}"></i>Band shaded by grade tier</span>` +
`<span class="legend-note">Points labeled value · percentile grade from the band tier (scale below)</span>`, `<span class="legend-note">Points labeled value · percentile; grade from the band tier (scale below)</span>`,
subtitle: `${s.label} recent trend vs normal`, subtitle: `${s.label}: recent trend vs normal`,
aria: `Recent ${s.label.toLowerCase()} versus the normal range.`, aria: `Recent ${s.label.toLowerCase()} versus the normal range.`,
}); });
} }
@ -220,7 +220,7 @@ export function precipChart(days, n, xFor, C) {
`<span><i style="background:${C.precip}"></i>Precipitation (dashed = median)</span>` + `<span><i style="background:${C.precip}"></i>Precipitation (dashed = median)</span>` +
`<span><i class="swatch-band" style="background:${hexA(TIER_COLORS["wet-6"], 0.5)};border-color:${TIER_COLORS["wet-6"]}"></i>Band shaded by rain-intensity tier</span>` + `<span><i class="swatch-band" style="background:${hexA(TIER_COLORS["wet-6"], 0.5)};border-color:${TIER_COLORS["wet-6"]}"></i>Band shaded by rain-intensity tier</span>` +
`<span class="legend-note">Rain days labeled amount · percentile (rain scale below)</span>`, `<span class="legend-note">Rain days labeled amount · percentile (rain scale below)</span>`,
subtitle: "Precipitation daily totals vs normal", subtitle: "Precipitation: daily totals vs normal",
aria: "Daily precipitation totals versus the normal range.", aria: "Daily precipitation totals versus the normal range.",
}); });
} }
@ -237,9 +237,9 @@ export function dryChart(days, n, xFor, C) {
fmtLabel: (v) => `${Math.round(v)}`, fmtLabel: (v) => `${Math.round(v)}`,
labelOn: (v) => v > 0, // label the streak length; rain days (0) just show the dot labelOn: (v) => v > 0, // label the streak length; rain days (0) just show the dot
legend: legend:
`<span><i style="background:${drynessColor(9)}"></i>Days since last rain dry streak</span>` + `<span><i style="background:${drynessColor(9)}"></i>Days since last rain: dry streak</span>` +
`<span><i style="background:${drynessColor(0)}"></i>Rain that day (streak = 0)</span>`, `<span><i style="background:${drynessColor(0)}"></i>Rain that day (streak = 0)</span>`,
subtitle: "Dry streak days since last measurable rain", subtitle: "Dry streak: days since last measurable rain",
aria: "Days since last measurable rain, as a line.", aria: "Days since last measurable rain, as a line.",
}); });
} }

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Thermograph compare comfort</title> <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="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" /> <meta name="theme-color" content="#f0803c" />
<!-- iOS/Android home-screen install: run standalone (no browser chrome) when <!-- iOS/Android home-screen install: run standalone (no browser chrome) when
@ -17,7 +17,7 @@
crawlers need absolute URLs and don't run JS. --> crawlers need absolute URLs and don't run JS. -->
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="Thermograph" /> <meta property="og:site_name" content="Thermograph" />
<meta property="og:title" content="Thermograph compare comfort" /> <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: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:url" content="__ORIGIN__/compare" />
<meta property="og:image" content="__ORIGIN__/logo.png?v=3" /> <meta property="og:image" content="__ORIGIN__/logo.png?v=3" />
@ -116,7 +116,7 @@
<div class="cmp-param cmp-season" id="cmp-season"> <div class="cmp-param cmp-season" id="cmp-season">
<span class="cal-filter-label">Time of year</span> <span class="cal-filter-label">Time of year</span>
<div class="cmp-season-dd" id="cmp-season-dd"></div> <div class="cmp-season-dd" id="cmp-season-dd"></div>
<span class="hint">Count only days in these seasons/months re-ranks instantly.</span> <span class="hint">Count only days in these seasons/months; re-ranks instantly.</span>
</div> </div>
<div class="cmp-param cmp-range cal-range" id="cmp-range"> <div class="cmp-param cmp-range cal-range" id="cmp-range">
@ -126,7 +126,7 @@
</div> </div>
<label>From <input id="cmp-start" type="month" /></label> <label>From <input id="cmp-start" type="month" /></label>
<label>To <input id="cmp-end" 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> <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> <button type="button" id="cmp-refresh-sheet" class="cmp-refresh-btn" hidden>Load / Refresh ↻</button>
</div> </div>
@ -142,7 +142,7 @@
<div class="placeholder" id="cmp-placeholder"> <div class="placeholder" id="cmp-placeholder">
<p>Add two or more places to see which one best matches your comfort temperature <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 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> often it runs colder or warmer, and by how much.</p>
</div> </div>

View file

@ -416,7 +416,7 @@ function rankCard(loc, s, rank) {
const win = rank === 1; const win = rank === 1;
const parts = BUCKETS.map(([cls, label, key]) => ({ cls, label, w: s[key] })).filter((p) => p.w > 0); const parts = BUCKETS.map(([cls, label, key]) => ({ cls, label, w: s[key] })).filter((p) => p.w > 0);
const bar = `<div class="cmp-bar" role="img" aria-label="of all days: ${parts.map((p) => `${pct(p.w)} ${p.label.toLowerCase()}`).join(", ")}">` + const bar = `<div class="cmp-bar" role="img" aria-label="of all days: ${parts.map((p) => `${pct(p.w)} ${p.label.toLowerCase()}`).join(", ")}">` +
parts.map((p) => `<span class="cmp-seg ${p.cls}" style="width:${p.w}%" title="${p.label} ${pct(p.w)}"></span>`).join("") + parts.map((p) => `<span class="cmp-seg ${p.cls}" style="width:${p.w}%" title="${p.label}: ${pct(p.w)}"></span>`).join("") +
`</div>`; `</div>`;
// Key under the bar: a color swatch + name + share for every non-empty bucket, in // Key under the bar: a color swatch + name + share for every non-empty bucket, in
// the same cold→hot order as the bar, so small spans are still clearly labeled. On // the same cold→hot order as the bar, so small spans are still clearly labeled. On
@ -510,7 +510,7 @@ function renderResults() {
const span = `${monthLabel(range.start)} ${monthLabel(range.end)}`; const span = `${monthLabel(range.start)} ${monthLabel(range.end)}`;
const winName = namePrimary(win.loc.name); const winName = namePrimary(win.loc.name);
const lead = scored.length > 1 const lead = scored.length > 1
? `<b>${winName}</b> is the best match for ${fmtTemp(lo)}${fmtTemp(hi)} score ${Math.round(win.s.score)}/100` ? `<b>${winName}</b> is the best match for ${fmtTemp(lo)}${fmtTemp(hi)}: score ${Math.round(win.s.score)}/100`
: `<b>${winName}</b>: match score ${Math.round(win.s.score)}/100 for ${fmtTemp(lo)}${fmtTemp(hi)}`; : `<b>${winName}</b>: match score ${Math.round(win.s.score)}/100 for ${fmtTemp(lo)}${fmtTemp(hi)}`;
head.hidden = false; head.hidden = false;
head.innerHTML = `<h2>${lead}</h2>` + head.innerHTML = `<h2>${lead}</h2>` +

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Thermograph a single day</title> <title>Thermograph: a single day</title>
<meta name="description" content="One day's weather in full detail: every percentile tier for that date and place, and exactly where the day's high, low, feels-like, wind and rain landed against local history." /> <meta name="description" content="One day's weather in full detail: every percentile tier for that date and place, and exactly where the day's high, low, feels-like, wind and rain landed against local history." />
<meta name="theme-color" content="#f0803c" /> <meta name="theme-color" content="#f0803c" />
<!-- iOS/Android home-screen install: run standalone (no browser chrome) when <!-- iOS/Android home-screen install: run standalone (no browser chrome) when
@ -17,7 +17,7 @@
crawlers need absolute URLs and don't run JS. --> crawlers need absolute URLs and don't run JS. -->
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="Thermograph" /> <meta property="og:site_name" content="Thermograph" />
<meta property="og:title" content="Thermograph a single day" /> <meta property="og:title" content="Thermograph: a single day" />
<meta property="og:description" content="One day's weather in full detail: every percentile tier for that date and place, and exactly where the day's high, low, feels-like, wind and rain landed against local history." /> <meta property="og:description" content="One day's weather in full detail: every percentile tier for that date and place, and exactly where the day's high, low, feels-like, wind and rain landed against local history." />
<meta property="og:url" content="__ORIGIN__/day" /> <meta property="og:url" content="__ORIGIN__/day" />
<meta property="og:image" content="__ORIGIN__/logo.png?v=3" /> <meta property="og:image" content="__ORIGIN__/logo.png?v=3" />
@ -76,7 +76,7 @@
<div id="day-head" class="cal-head" hidden></div> <div id="day-head" class="cal-head" hidden></div>
<div class="placeholder" id="day-placeholder"> <div class="placeholder" id="day-placeholder">
<p>Pick a place and a day — or open this from a day on the map or calendar — to see <p>Pick a place and a day, or open this from a day on the map or calendar, to see
the value at every tier boundary for that day's typical climate.</p> the value at every tier boundary for that day's typical climate.</p>
</div> </div>
<div id="day-body"></div> <div id="day-body"></div>

View file

@ -48,7 +48,7 @@ for (const form of document.querySelectorAll("form[data-digest]")) {
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (status) { if (status) {
status.textContent = data.message || "Something went wrong — try again."; status.textContent = data.message || "Something went wrong. Try again.";
status.hidden = false; status.hidden = false;
} }
if (res.ok) { if (res.ok) {
@ -60,7 +60,7 @@ for (const form of document.querySelectorAll("form[data-digest]")) {
} }
} catch { } catch {
if (status) { if (status) {
status.textContent = "Couldn't reach the server — try again."; status.textContent = "Couldn't reach the server. Try again.";
status.hidden = false; status.hidden = false;
} }
} finally { } finally {

View file

@ -49,7 +49,7 @@ function build() {
'<path d="M12 3v12"/><path d="M8 7l4-4 4 4"/>' + '<path d="M12 3v12"/><path d="M8 7l4-4 4 4"/>' +
'<path d="M5 12v7a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-7"/></svg>' + '<path d="M5 12v7a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-7"/></svg>' +
'<div class="ios-a2hs-text"><b>Add Thermograph to your Home Screen</b>' + '<div class="ios-a2hs-text"><b>Add Thermograph to your Home Screen</b>' +
'<span>Tap Share, then <b>Add to Home Screen</b> — its how to get weather alerts on iPhone.</span></div>' + '<span>Tap Share, then <b>Add to Home Screen</b>. Its how to get weather alerts on iPhone.</span></div>' +
'<button type="button" class="ios-a2hs-x" aria-label="Dismiss">×</button>'; '<button type="button" class="ios-a2hs-x" aria-label="Dismiss">×</button>';
el.querySelector(".ios-a2hs-x").addEventListener("click", () => { el.querySelector(".ios-a2hs-x").addEventListener("click", () => {
remember(); remember();

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Thermograph reading the grades</title> <title>Thermograph: reading the grades</title>
<meta name="description" content="How Thermograph grades weather: percentiles of a location's own ±7-day seasonal history, the scale from Below Normal to Near Record, and what every metric means." /> <meta name="description" content="How Thermograph grades weather: percentiles of a location's own ±7-day seasonal history, the scale from Below Normal to Near Record, and what every metric means." />
<meta name="theme-color" content="#f0803c" /> <meta name="theme-color" content="#f0803c" />
<!-- iOS/Android home-screen install: run standalone (no browser chrome) when <!-- iOS/Android home-screen install: run standalone (no browser chrome) when
@ -17,7 +17,7 @@
crawlers need absolute URLs and don't run JS. --> crawlers need absolute URLs and don't run JS. -->
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="Thermograph" /> <meta property="og:site_name" content="Thermograph" />
<meta property="og:title" content="Thermograph reading the grades" /> <meta property="og:title" content="Thermograph: reading the grades" />
<meta property="og:description" content="How Thermograph grades weather: percentiles of a location's own ±7-day seasonal history, the scale from Below Normal to Near Record, and what every metric means." /> <meta property="og:description" content="How Thermograph grades weather: percentiles of a location's own ±7-day seasonal history, the scale from Below Normal to Near Record, and what every metric means." />
<meta property="og:url" content="__ORIGIN__/legend" /> <meta property="og:url" content="__ORIGIN__/legend" />
<meta property="og:image" content="__ORIGIN__/logo.png?v=3" /> <meta property="og:image" content="__ORIGIN__/logo.png?v=3" />
@ -66,17 +66,17 @@
decades of local records, then reports the <b>percentile</b> where the day falls in it.</p> decades of local records, then reports the <b>percentile</b> where the day falls in it.</p>
<p>So a 60&deg; day can be &ldquo;Above Normal&rdquo; in one place or season and <p>So a 60&deg; day can be &ldquo;Above Normal&rdquo; in one place or season and
&ldquo;Below Normal&rdquo; in another. That's why the categories read &ldquo;Below Normal&rdquo; in another. That's why the categories read
&ldquo;Above/Below Normal&rdquo;, &ldquo;High/Low&rdquo;, and &ldquo;Near Record&rdquo; &ldquo;Above/Below Normal&rdquo;, &ldquo;High/Low&rdquo;, and &ldquo;Near Record&rdquo;,
never &ldquo;hot&rdquo; or &ldquo;cold&rdquo;.</p> never &ldquo;hot&rdquo; or &ldquo;cold&rdquo;.</p>
</section> </section>
<section class="guide-block"> <section class="guide-block">
<h2>Grade scale percentile of the local &plusmn;7-day history</h2> <h2>Grade scale: percentile of the local &plusmn;7-day history</h2>
<div class="guide-scale" id="temp-scale"></div> <div class="guide-scale" id="temp-scale"></div>
</section> </section>
<section class="guide-block"> <section class="guide-block">
<h2>Rain intensity percentile among rain days</h2> <h2>Rain intensity: percentile among rain days</h2>
<p class="muted">Precipitation is graded only across days that actually saw rain, so <p class="muted">Precipitation is graded only across days that actually saw rain, so
&ldquo;Heavy&rdquo; means heavy <i>for a rainy day here</i>. Dry days are colored by &ldquo;Heavy&rdquo; means heavy <i>for a rainy day here</i>. Dry days are colored by
their dry streak instead (below).</p> their dry streak instead (below).</p>
@ -87,14 +87,14 @@
<h2>The metrics</h2> <h2>The metrics</h2>
<dl class="guide-metrics"> <dl class="guide-metrics">
<dt>High / Low</dt><dd>The day's highest and lowest air temperature (<span data-unit-label="temp">&deg;F</span>).</dd> <dt>High / Low</dt><dd>The day's highest and lowest air temperature (<span data-unit-label="temp">&deg;F</span>).</dd>
<dt>Feels</dt><dd>Apparent temperature what the air actually felt like once humidity <dt>Feels</dt><dd>Apparent temperature: what the air actually felt like once humidity
and wind are factored in, taking whichever apparent extreme (heat-index high or and wind are factored in, taking whichever apparent extreme (heat-index high or
wind-chill low) sits further from a temperate baseline, graded against its own history.</dd> wind-chill low) sits further from a temperate baseline, graded against its own history.</dd>
<dt>Humid</dt><dd>Absolute humidity: grams of water vapor per cubic meter of air (g/m&sup3;).</dd> <dt>Humid</dt><dd>Absolute humidity: grams of water vapor per cubic meter of air (g/m&sup3;).</dd>
<dt>Wind</dt><dd>Average sustained wind speed (<span data-unit-label="wind">mph</span>).</dd> <dt>Wind</dt><dd>Average sustained wind speed (<span data-unit-label="wind">mph</span>).</dd>
<dt>Gust</dt><dd>Peak wind gust for the day (<span data-unit-label="wind">mph</span>).</dd> <dt>Gust</dt><dd>Peak wind gust for the day (<span data-unit-label="wind">mph</span>).</dd>
<dt>Precip</dt><dd>Total precipitation (<span data-unit-label="precip">inches</span>), graded by intensity among rain days.</dd> <dt>Precip</dt><dd>Total precipitation (<span data-unit-label="precip">inches</span>), graded by intensity among rain days.</dd>
<dt>Dry streak</dt><dd>Consecutive days since the last measurable rain the color deepens <dt>Dry streak</dt><dd>Consecutive days since the last measurable rain; the color deepens
the longer it's been dry, and resets to blue on a rain day.</dd> the longer it's been dry, and resets to blue on a rain day.</dd>
</dl> </dl>
</section> </section>

View file

@ -121,7 +121,7 @@ function renderSug(list) {
if (!shown.length) { sugEl.hidden = true; return; } if (!shown.length) { sugEl.hidden = true; return; }
shown.forEach((r) => { shown.forEach((r) => {
const li = document.createElement("li"); const li = document.createElement("li");
li.innerHTML = `${r.name}<span class="sub"> ${[r.admin1, r.country].filter(Boolean).join(", ")}</span>`; li.innerHTML = `${r.name}<span class="sub">, ${[r.admin1, r.country].filter(Boolean).join(", ")}</span>`;
// A picked search result is an unambiguous choice — drop the pin, recenter, // A picked search result is an unambiguous choice — drop the pin, recenter,
// and select it right away (the map is there for manual/fine picks). // and select it right away (the map is there for manual/fine picks).
li.onclick = () => { li.onclick = () => {
@ -146,7 +146,7 @@ function setPin(lat, lon, zoom) {
else marker = L.marker([lat, lon], pinIcon ? { icon: pinIcon } : undefined).addTo(map); else marker = L.marker([lat, lon], pinIcon ? { icon: pinIcon } : undefined).addTo(map);
map.setView([lat, lon], zoom || map.getZoom()); map.setView([lat, lon], zoom || map.getZoom());
confirmBtn.disabled = false; confirmBtn.disabled = false;
hintEl.textContent = "Pin set — confirm below, or tap elsewhere to move it."; hintEl.textContent = "Pin set. Confirm below, or tap elsewhere to move it.";
} }
function finish(p) { function finish(p) {

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Thermograph climate score</title> <title>Thermograph: climate score</title>
<meta name="description" content="How far a place's last six years have drifted from its 45-year climate: a per-metric and overall climate-shift score across temperature, feels-like, humidity, wet bulb, wind and rain." /> <meta name="description" content="How far a place's last six years have drifted from its 45-year climate: a per-metric and overall climate-shift score across temperature, feels-like, humidity, wet bulb, wind and rain." />
<meta name="theme-color" content="#f0803c" /> <meta name="theme-color" content="#f0803c" />
<!-- Link previews (Discord, Slack, iMessage…). The server fills the og:url / <!-- Link previews (Discord, Slack, iMessage…). The server fills the og:url /
@ -11,7 +11,7 @@
crawlers need absolute URLs and don't run JS. --> crawlers need absolute URLs and don't run JS. -->
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="Thermograph" /> <meta property="og:site_name" content="Thermograph" />
<meta property="og:title" content="Thermograph climate score" /> <meta property="og:title" content="Thermograph: climate score" />
<meta property="og:description" content="How far a place's last six years have drifted from its 45-year climate: a per-metric and overall climate-shift score across temperature, feels-like, humidity, wet bulb, wind and rain." /> <meta property="og:description" content="How far a place's last six years have drifted from its 45-year climate: a per-metric and overall climate-shift score across temperature, feels-like, humidity, wet bulb, wind and rain." />
<meta property="og:url" content="__ORIGIN__/score" /> <meta property="og:url" content="__ORIGIN__/score" />
<meta property="og:image" content="__ORIGIN__/logo.png?v=2" /> <meta property="og:image" content="__ORIGIN__/logo.png?v=2" />
@ -63,7 +63,7 @@
<div id="score-head" hidden></div> <div id="score-head" hidden></div>
<div class="placeholder" id="score-placeholder"> <div class="placeholder" id="score-placeholder">
<p>Pick a place — or open this from the map or calendar — to see how much its <p>Pick a place, or open this from the map or calendar, to see how much its
recent climate has drifted from its long-term normal, metric by metric.</p> recent climate has drifted from its long-term normal, metric by metric.</p>
</div> </div>
<div id="score-body"></div> <div id="score-body"></div>

View file

@ -158,7 +158,7 @@ function wetbulbNote(m, baseYrs) {
const delta = d ? ` <span class="pq-d ${d >= 0 ? "pq-up" : "pq-down"}">(${d >= 0 ? "+" : ""}${d} pts)</span>` : ""; const delta = d ? ` <span class="pq-d ${d >= 0 ? "pq-up" : "pq-down"}">(${d >= 0 ? "+" : ""}${d} pts)</span>` : "";
return `<p class="score-callout">${def} return `<p class="score-callout">${def}
Heat-stress days (peak wet bulb ${esc(String(thr))}): <strong>${f6}%</strong> of recent days Heat-stress days (peak wet bulb ${esc(String(thr))}): <strong>${f6}%</strong> of recent days
vs <strong>${f45}%</strong> across ${baseYrs}${delta} the rest are normal.</p>`; vs <strong>${f45}%</strong> across ${baseYrs}${delta}. The rest are normal.</p>`;
} }
// One metric score card. The tier and its direction stack under the score on // One metric score card. The tier and its direction stack under the score on
@ -208,9 +208,9 @@ function sentencesHTML(annual) {
.map((m) => { .map((m) => {
const mid = m.per_q && m.per_q.find((q) => q.q === 50); const mid = m.per_q && m.per_q.find((q) => q.q === 50);
const where = mid ? ` At the median, recent readings sit about ${Math.abs(mid.d)} points ${mid.d >= 0 ? "higher" : "lower"} than the seasonal norm.` : ""; const where = mid ? ` At the median, recent readings sit about ${Math.abs(mid.d)} points ${mid.d >= 0 ? "higher" : "lower"} than the seasonal norm.` : "";
return `<li><strong>${esc(m.label)}:</strong> ${esc(m.grade)} about ${Math.abs(m.bias).toFixed(0)} percentile points ${esc(m.direction)} on average.${where}</li>`; return `<li><strong>${esc(m.label)}:</strong> ${esc(m.grade)}, about ${Math.abs(m.bias).toFixed(0)} percentile points ${esc(m.direction)} on average.${where}</li>`;
}); });
if (!lines.length) return `<p class="score-summary-lead">Every metric is close to its long-term normal here no notable recent drift.</p>`; if (!lines.length) return `<p class="score-summary-lead">Every metric is close to its long-term normal here, with no notable recent drift.</p>`;
return `<p class="score-summary-lead">What has shifted the most:</p><ul class="score-sentences">${lines.join("")}</ul>`; return `<p class="score-summary-lead">What has shifted the most:</p><ul class="score-sentences">${lines.join("")}</ul>`;
} }

View file

@ -3,8 +3,8 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Thermograph weather alerts</title> <title>Thermograph: weather alerts</title>
<meta name="description" content="Subscribe to a city and get notified when its weather is unusually extreme above the percentile you choose." /> <meta name="description" content="Subscribe to a city and get notified when its weather is unusually extreme, above the percentile you choose." />
<meta name="theme-color" content="#f0803c" /> <meta name="theme-color" content="#f0803c" />
<!-- iOS/Android home-screen install: run standalone (no browser chrome) when <!-- 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. --> added to the Home Screen. On iOS 16.4+ this is what unlocks Web Push. -->
@ -14,8 +14,8 @@
<meta name="apple-mobile-web-app-title" content="Thermograph" /> <meta name="apple-mobile-web-app-title" content="Thermograph" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="Thermograph" /> <meta property="og:site_name" content="Thermograph" />
<meta property="og:title" content="Thermograph weather alerts" /> <meta property="og:title" content="Thermograph: weather alerts" />
<meta property="og:description" content="Subscribe to a city and get notified when its weather is unusually extreme above the percentile you choose." /> <meta property="og:description" content="Subscribe to a city and get notified when its weather is unusually extreme, above the percentile you choose." />
<meta property="og:url" content="__ORIGIN__/alerts" /> <meta property="og:url" content="__ORIGIN__/alerts" />
<meta property="og:image" content="__ORIGIN__/logo.png?v=3" /> <meta property="og:image" content="__ORIGIN__/logo.png?v=3" />
<meta property="og:image:width" content="512" /> <meta property="og:image:width" content="512" />

View file

@ -45,7 +45,7 @@ async function readErr(res) {
async function load() { async function load() {
let res; let res;
try { res = await apiFetch("api/v2/subscriptions"); } try { res = await apiFetch("api/v2/subscriptions"); }
catch (e) { body.innerHTML = `<p class="alerts-loading">Offline — can't load alerts.</p>`; return; } catch (e) { body.innerHTML = `<p class="alerts-loading">Offline. Can't load alerts.</p>`; return; }
if (res.status === 401) { renderGate(); return; } if (res.status === 401) { renderGate(); return; }
if (!res.ok) { body.innerHTML = `<p class="alerts-loading">${esc(await readErr(res))}</p>`; return; } if (!res.ok) { body.innerHTML = `<p class="alerts-loading">${esc(await readErr(res))}</p>`; return; }
subs = await res.json(); subs = await res.json();
@ -57,14 +57,14 @@ function renderGate() {
<div class="alerts-gate panel"> <div class="alerts-gate panel">
<h2>What are weather alerts?</h2> <h2>What are weather alerts?</h2>
<p class="alerts-lede">Thermograph can watch any city and tell you when its weather turns <p class="alerts-lede">Thermograph can watch any city and tell you when its weather turns
<b>unusually extreme for that place</b> a heat spell, a cold snap, heavy rain graded <b>unusually extreme for that place</b> (a heat spell, a cold snap, heavy rain), graded
against ~45 years of local climate history.</p> against ~45 years of local climate history.</p>
<ol class="alerts-explain"> <ol class="alerts-explain">
<li><b>Pick a city</b> and the metrics to watch high, low, feels-like, rain, wind</li> <li><b>Pick a city</b> and the metrics to watch: high, low, feels-like, rain, wind</li>
<li><b>Set a threshold.</b> Alert when a day passes, say, the <b>97th percentile</b> for <li><b>Set a threshold.</b> Alert when a day passes, say, the <b>97th percentile</b> for
that location. Higher = rarer, so fewer but more significant alerts.</li> that location. Higher = rarer, so fewer but more significant alerts.</li>
<li><b>Get notified</b> in-app (and via OS push on this device) when a recorded day or <li><b>Get notified</b> in-app (and via OS push on this device) when a recorded day, or
the upcoming forecast crosses your threshold. At most one notification per week per alert.</li> the upcoming forecast, crosses your threshold. At most one notification per week per alert.</li>
</ol> </ol>
<p class="alerts-eg muted">For example: Tell me when Tokyo's high passes the 97th percentile.</p> <p class="alerts-eg muted">For example: Tell me when Tokyo's high passes the 97th percentile.</p>
<button type="button" class="acct-submit" id="gate-signin">Sign in / Create account</button> <button type="button" class="acct-submit" id="gate-signin">Sign in / Create account</button>
@ -109,7 +109,7 @@ async function renderPushBar() {
} }
if (pushClient.permission() === "denied") { if (pushClient.permission() === "denied") {
el.hidden = false; el.hidden = false;
el.innerHTML = `<p class="push-note muted">Notifications are blocked for this site — re-enable el.innerHTML = `<p class="push-note muted">Notifications are blocked for this site. Re-enable
them in your browser settings to get OS alerts here.</p>`; them in your browser settings to get OS alerts here.</p>`;
return; return;
} }