Compare/calendar mobile polish: overflow fix, sheet load button, 6-yr default, country in names (#80)
- Fix the mobile width blow-out: the distribution grid item (.cmp-dist-row) had no min-width:0, so each row expanded to the 9–10-column connected bar chart's min-content and widened the whole page (zoom-out, clipped filter sheet). Add grid-template-columns: minmax(0,1fr) + min-width:0 so the strip scrolls inside its own .ct-strip, and tighten .ct-col to 34px on phones. - Add a Load/Refresh button inside the compare filter sheet's date-range block, wired to the same refresh()/isDirty(); editing the range on mobile no longer needs the sheet closed. The existing button stays in the controls (loads added places). - Default date range on both pages is now January six years back → the present. On the calendar this is an explicit chunked range (the months=24 path is server-capped at ~2yr). - Names now include country: reverse_geocode appends it to the place label; revgeo cache key gets a v2 prefix and PAYLOAD_VER bumps to p2 so labels/payloads repopulate. - Location names read as a hierarchy: compact chips show just the lead segment (full name in the title/aria), and each ranked card shows the lead segment bold over a muted "city · region · country" line. Frontend + a small backend name/cache change; no schema migration.
This commit is contained in:
parent
d38bf0192c
commit
f3ba056738
5 changed files with 65 additions and 20 deletions
|
|
@ -71,9 +71,12 @@ function weatherPass(rec) {
|
|||
const wt = weatherType(rec);
|
||||
return (!wt.feel || checkedFeels.has(wt.feel)) && checkedSkies.has(wt.sky);
|
||||
}
|
||||
// Custom date range (ISO strings) or null = default last-24-months. Any span is
|
||||
// allowed; the frontend splits it into ~2-year server requests (see fetchCalendar).
|
||||
let rangeStart = null, rangeEnd = null;
|
||||
// Custom date range (ISO strings). Defaults to January six years back → today; the
|
||||
// frontend splits any span into ~2-year server requests (see fetchCalendar), so a
|
||||
// 6-year default loads as a few chunks rather than the old server-capped last-24.
|
||||
const defaultCalStart = () => isoOfDate(new Date(new Date().getFullYear() - 6, 0, 1));
|
||||
const defaultCalEnd = () => isoOfDate(new Date());
|
||||
let rangeStart = defaultCalStart(), rangeEnd = defaultCalEnd();
|
||||
// The full requested span (drives the filters + pickers); data.range tracks only
|
||||
// the portion graded so far, which grows as chunks arrive.
|
||||
let reqStart = null, reqEnd = null;
|
||||
|
|
@ -690,7 +693,7 @@ function attachHover(byDate) {
|
|||
const savedFrom = saved && saved.start ? saved.start : null;
|
||||
rangeStart = (savedFrom && toFirst > savedFrom)
|
||||
? savedFrom // To after From → keep From
|
||||
: isoOfDate(new Date(y - 2, m, 1)); // To ≤ From (or none) → same month, 2yr back
|
||||
: isoOfDate(new Date(y - 6, m, 1)); // To ≤ From (or none) → same month, 6yr back
|
||||
saveCalRange(rangeStart, rangeEnd);
|
||||
setCalSync(sel);
|
||||
} else if (saved) {
|
||||
|
|
|
|||
|
|
@ -105,7 +105,8 @@
|
|||
</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 Refresh — 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ import { MONTHS, pad, monthStart, monthEnd, buildChunks,
|
|||
clickOpensPicker, metricBuckets, distStrip,
|
||||
seasonFilterDropdown, seasonSummaryText, syncSeasonChecks,
|
||||
applySeasonMonthChange, initSeasonExpand,
|
||||
allMonths, monthsToMask, maskToMonths } from "./shared.js";
|
||||
allMonths, monthsToMask, maskToMonths,
|
||||
namePrimary, nameParts } from "./shared.js";
|
||||
import { fmtTemp, fmtDelta, onUnitChange } from "./units.js";
|
||||
import { initFilterSheet } from "./filtersheet.js";
|
||||
|
||||
|
|
@ -107,8 +108,8 @@ const BASIS_LABEL = { tmax: "daytime high", mean: "daily mean", tmin: "overnight
|
|||
const isYM = (s) => typeof s === "string" && /^\d{4}-\d{2}$/.test(s);
|
||||
function defaultRange() {
|
||||
const now = new Date();
|
||||
const s = new Date(now.getFullYear(), now.getMonth() - 11, 1); // last 12 whole months
|
||||
return { start: `${s.getFullYear()}-${pad(s.getMonth() + 1)}`, end: `${now.getFullYear()}-${pad(now.getMonth() + 1)}` };
|
||||
// January six years back → the present month (a broad seasonal sample by default).
|
||||
return { start: `${now.getFullYear() - 6}-01`, end: `${now.getFullYear()}-${pad(now.getMonth() + 1)}` };
|
||||
}
|
||||
function loadRange() {
|
||||
try { const o = JSON.parse(lsGet(CMP.range)); if (o && isYM(o.start) && isYM(o.end)) return o; } catch (e) {}
|
||||
|
|
@ -166,6 +167,10 @@ function readHashState() {
|
|||
// ---- elements ----
|
||||
const addBtn = document.getElementById("cmp-add");
|
||||
const refreshBtn = document.getElementById("cmp-refresh");
|
||||
// A second Load/Refresh lives inside the params sheet's date-range block, so editing
|
||||
// the range on mobile (where the panel is a bottom sheet) can reload without closing.
|
||||
const refreshBtnSheet = document.getElementById("cmp-refresh-sheet");
|
||||
const refreshBtns = [refreshBtn, refreshBtnSheet];
|
||||
const locList = document.getElementById("cmp-loc-list");
|
||||
const params = document.getElementById("cmp-params");
|
||||
const placeholder = document.getElementById("cmp-placeholder");
|
||||
|
|
@ -373,7 +378,9 @@ function renderLocList() {
|
|||
else if (l.error) { label = "Couldn't load"; cls += " error"; }
|
||||
else if (!l.series) { label = l.name || coords; cls += " pending"; lead = `<span class="cmp-chip-dot" aria-hidden="true"></span>`; }
|
||||
else { label = l.name; }
|
||||
return `<li class="${cls}">${lead}<span class="cmp-chip-name">${label}</span>` +
|
||||
// The chip shows just the lead segment (compact, several per row); the full name
|
||||
// stays in the title + aria-label and on the ranked card below.
|
||||
return `<li class="${cls}">${lead}<span class="cmp-chip-name" title="${label}">${namePrimary(label)}</span>` +
|
||||
`<button type="button" class="cmp-chip-x" data-i="${i}" aria-label="Remove ${label}">×</button></li>`;
|
||||
}).join("");
|
||||
}
|
||||
|
|
@ -393,6 +400,13 @@ const BUCKETS = [
|
|||
// site's own colors), and directly beneath it a key that names every bucket and its
|
||||
// share — so even a 1% sliver on the bar reads clearly. The whole bar is 100% of the
|
||||
// days, so more blue = runs cold, more red = runs hot.
|
||||
// The place name as a hierarchy: the lead segment bold, the rest (city · region ·
|
||||
// country) on a muted line beneath — the one spot that shows the full name.
|
||||
function nameHierarchy(name) {
|
||||
const { primary, rest } = nameParts(name);
|
||||
return `<span class="cmp-name"><b>${primary}</b>${rest ? `<span class="cmp-name-sub">${rest}</span>` : ""}</span>`;
|
||||
}
|
||||
|
||||
function rankCard(loc, s, rank) {
|
||||
const win = rank === 1;
|
||||
const parts = BUCKETS.map(([cls, label, key]) => ({ cls, label, w: s[key] })).filter((p) => p.w > 0);
|
||||
|
|
@ -417,7 +431,7 @@ function rankCard(loc, s, rank) {
|
|||
return `<div class="cmp-card${win ? " cmp-win" : ""}">` +
|
||||
`<div class="cmp-card-top">` +
|
||||
`<span class="cmp-rank">${rank}</span>` +
|
||||
`<div class="cmp-card-name"><span class="cmp-name">${loc.name}</span><span class="cmp-daycount">${sub}</span></div>` +
|
||||
`<div class="cmp-card-name">${nameHierarchy(loc.name)}<span class="cmp-daycount">${sub}</span></div>` +
|
||||
`<div class="cmp-big"><b>${Math.round(s.score)}</b><span>match / 100</span></div>` +
|
||||
`</div>` +
|
||||
bar + key + bands +
|
||||
|
|
@ -448,7 +462,7 @@ function renderBaseline(scored) {
|
|||
const rows = scored.map(({ loc, s }) => {
|
||||
const cls = posClass(s.avgTemp), a = posF(s.avgTemp);
|
||||
return `<div class="cmp-bl-row">` +
|
||||
`<span class="cmp-bl-name">${loc.name}</span>` +
|
||||
`<span class="cmp-bl-name" title="${loc.name}">${namePrimary(loc.name)}</span>` +
|
||||
`<div class="cmp-bl-track" role="img" aria-label="${loc.name}: average ${fmtTemp(s.avgTemp)}, typical ${fmtTemp(s.p10)} to ${fmtTemp(s.p90)}">` +
|
||||
`<span class="cmp-bl-tol" style="left:${tolL}%;right:${100 - tolR}%"></span>` +
|
||||
`<span class="cmp-bl-band" style="left:${bandL}%;right:${100 - bandR}%"></span>` +
|
||||
|
|
@ -491,9 +505,10 @@ function renderResults() {
|
|||
// Headline: the winner + the parameters it was judged under.
|
||||
const win = scored[0];
|
||||
const span = `${monthLabel(range.start)} – ${monthLabel(range.end)}`;
|
||||
const winName = namePrimary(win.loc.name);
|
||||
const lead = scored.length > 1
|
||||
? `<b>${win.loc.name}</b> is the best match for ${fmtTemp(lo)}–${fmtTemp(hi)} — score ${Math.round(win.s.score)}/100`
|
||||
: `<b>${win.loc.name}</b>: match score ${Math.round(win.s.score)}/100 for ${fmtTemp(lo)}–${fmtTemp(hi)}`;
|
||||
? `<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)}`;
|
||||
head.hidden = false;
|
||||
head.innerHTML = `<h2>${lead}</h2>` +
|
||||
`<p class="meta">By ${BASIS_LABEL[basis]} · comfort ${fmtTemp(lo)}–${fmtTemp(hi)}, tolerance ${fmtTemp(tlo)}–${fmtTemp(thi)} · ${span}` +
|
||||
|
|
@ -516,10 +531,10 @@ function renderDist() {
|
|||
distBody.innerHTML = withData.map(({ l, vis }) => {
|
||||
const buckets = metricBuckets(vis, distMetric);
|
||||
const total = buckets.reduce((n, b) => n + b.n, 0);
|
||||
const name = l.name || `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`;
|
||||
const name = namePrimary(l.name) || `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`;
|
||||
const strip = total ? distStrip(buckets, distCount) : `<p class="muted">No data for this metric.</p>`;
|
||||
return `<div class="cmp-dist-row">` +
|
||||
`<div class="cmp-dist-name">${name}<span class="cmp-dist-days">${total.toLocaleString()} days</span></div>` +
|
||||
`<div class="cmp-dist-name" title="${l.name || ""}">${name}<span class="cmp-dist-days">${total.toLocaleString()} days</span></div>` +
|
||||
strip + `</div>`;
|
||||
}).join("");
|
||||
distSection.hidden = false;
|
||||
|
|
@ -530,7 +545,7 @@ function renderAll() {
|
|||
params.hidden = !has;
|
||||
placeholder.hidden = has;
|
||||
addBtn.querySelector("span").textContent = has ? "Add another location" : "Add location";
|
||||
refreshBtn.hidden = !isDirty();
|
||||
for (const b of refreshBtns) b.hidden = !isDirty();
|
||||
renderLocList();
|
||||
renderResults();
|
||||
renderDist();
|
||||
|
|
@ -541,7 +556,7 @@ initFindButton(addBtn, "Add location",
|
|||
() => (locations.length ? locations[locations.length - 1] : loadLastLocation()),
|
||||
(lat, lon) => addLocation(lat, lon));
|
||||
|
||||
refreshBtn.addEventListener("click", refresh);
|
||||
for (const b of refreshBtns) b.addEventListener("click", refresh);
|
||||
|
||||
locList.addEventListener("click", (e) => {
|
||||
const x = e.target.closest(".cmp-chip-x");
|
||||
|
|
|
|||
|
|
@ -192,6 +192,17 @@ export const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<")
|
|||
export const placeLabel = (data) =>
|
||||
data.place || `${data.cell.center_lat.toFixed(3)}, ${data.cell.center_lon.toFixed(3)}`;
|
||||
|
||||
// A place label is "neighbourhood, city, region, country" (comma-joined by the
|
||||
// backend). For display we lead with the first segment and mute the rest:
|
||||
// namePrimary → the lead segment; nameParts → { primary, rest } with rest joined by
|
||||
// " · " (e.g. "Seattle · Washington · United States"). Tolerant of 1–4 parts.
|
||||
export const namePrimary = (name) =>
|
||||
String(name || "").split(",")[0].trim() || String(name || "");
|
||||
export function nameParts(name) {
|
||||
const segs = String(name || "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
return { primary: segs[0] || String(name || ""), rest: segs.slice(1).join(" · ") };
|
||||
}
|
||||
|
||||
// ---- dates & range chunking ----
|
||||
export const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
export const pad = (n) => String(n).padStart(2, "0");
|
||||
|
|
|
|||
|
|
@ -854,7 +854,10 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
|
|||
}
|
||||
.cmp-refresh-btn[hidden] { display: none; }
|
||||
.cmp-refresh-btn:hover { filter: brightness(1.05); }
|
||||
.cmp-chip-name { font-weight: 600; }
|
||||
.cmp-chip-name {
|
||||
font-weight: 600; max-width: 44vw;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.cmp-chip-x {
|
||||
border: none; background: transparent; color: var(--muted); cursor: pointer;
|
||||
font-size: 20px; line-height: 1; width: 30px; height: 30px; border-radius: 999px;
|
||||
|
|
@ -889,7 +892,11 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
|
|||
background: var(--surface-2); color: var(--muted);
|
||||
}
|
||||
.cmp-card-name { flex: 1 1 auto; min-width: 0; }
|
||||
.cmp-name { display: block; font-size: 16px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; }
|
||||
/* Name hierarchy: the lead segment bold, the rest (city · region · country) muted
|
||||
beneath — both truncate rather than wrap so the card head stays one tidy block. */
|
||||
.cmp-name { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.cmp-name b { font-size: 16px; font-weight: 700; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cmp-name-sub { font-size: 12.5px; color: var(--muted); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cmp-daycount { font-size: 12px; color: var(--muted); }
|
||||
.cmp-big { flex: 0 0 auto; text-align: right; }
|
||||
.cmp-big b { display: block; font-size: 22px; color: var(--normal); line-height: 1.1; }
|
||||
|
|
@ -1037,8 +1044,12 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
|
|||
.cmp-dist-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; }
|
||||
.cmp-dist-controls .cal-filter-label { margin: 0; }
|
||||
.cmp-dist .metric-toggle { margin: 0; }
|
||||
.cmp-dist-body { display: grid; gap: 12px; margin-top: 14px; }
|
||||
/* minmax(0,1fr) + the row's min-width:0 let the track shrink below the strip's
|
||||
min-content, so the connected bar chart scrolls inside its own .ct-strip
|
||||
(overflow-x:auto) instead of widening the whole page on a phone. */
|
||||
.cmp-dist-body { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; margin-top: 14px; }
|
||||
.cmp-dist-row {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border); border-radius: 12px; background: var(--surface); padding: 12px 16px 6px;
|
||||
}
|
||||
.cmp-dist-name { display: flex; align-items: baseline; gap: 8px; font-size: 14px; font-weight: 700; }
|
||||
|
|
@ -1126,6 +1137,10 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
|
|||
.guide-metrics { grid-template-columns: 1fr; gap: 2px 0; }
|
||||
.guide-metrics dt { margin-top: 10px; }
|
||||
|
||||
/* Tighter distribution columns so a 9-tier temp strip fits a phone without
|
||||
scrolling (precip's 10th column still scrolls inside its own .ct-strip). */
|
||||
.ct-col { min-width: 34px; }
|
||||
|
||||
.cmp-params { grid-template-columns: 1fr; }
|
||||
.cmp-basis-block .metric-toggle button { flex: 1 0 22%; padding: 10px 4px; }
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue