From cd3d1733a095e7d934a488a569a789f3e9d99a7e Mon Sep 17 00:00:00 2001 From: Boris De Vloed Date: Fri, 11 Sep 2026 18:55:51 +0200 Subject: [PATCH 01/42] feat(analytics): honest event tracking, sentence-form aire parser, villages guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app.js: the page-load open of a // landing no longer fires Appellation Viewed — the pageview already records the slug, and the custom event made every entity landing a non-bounce by construction and let sessions start with a custom event. Appellation Viewed gains `via` (map / cycle / facet / omnisearch / panel-link) and `stack_size`; new `Feedback Clicked` {channel: github|email} on the sidebar and About-dialog links (data-feedback attributes in map_template.py). - stage 02 extract_aire: recognise degree-less section-IV sub-block headers ("1 - Aire géographique") and sentence-form aires ("sur le territoire de la commune de Mâcon du département de Saône-et-Loire"), dropping lowercase asides. Pouilly-Loché's aire was the 366-commune proximité list (drawn across all of Burgundy in simple mode); it is now Mâcon, and 58 other single-commune AOCs gained a previously empty aire. A partial --only run no longer stubs the unselected records or shrinks _index.json. - stage 04: [villages-guard] narrows a cahier-text commune union spanning more than 20x the parcellaire bbox to parcel-bearing communes (dormant for correct records). - docs/analytics.md: event/prop reference, goal recipe, reading artefacts; CLAUDE.md pointer + scripts-contract note; CURATOR_TODO entries (Pouilly-Loché aire, canonical grape-slug ranking fragility); six parser regression tests. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 18 +++++- CURATOR_TODO.md | 28 +++++++++ docs/analytics.md | 83 +++++++++++++++++++++++++++ scripts/02_extract_cahiers.py | 80 +++++++++++++++++++++++--- scripts/04_build_maps.py | 49 ++++++++++++++++ scripts/_lib/assets/app.js | 33 ++++++++--- scripts/_lib/map_template.py | 7 ++- tests/test_fr_cahier_parser.py | 101 +++++++++++++++++++++++++++++++++ 8 files changed, 379 insertions(+), 20 deletions(-) create mode 100644 docs/analytics.md diff --git a/CLAUDE.md b/CLAUDE.md index 65f8729..36b70a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -553,7 +553,11 @@ generator against `wiki/_index.json`. ## Scripts contract Each script is independently re-runnable and writes a manifest. Running stage N -twice with no changes upstream must be a no-op (cache hits). +twice with no changes upstream must be a no-op (cache hits). Stage 02's +`--only NAME` (repeatable substring) re-extracts just the matching records and +merges their entries into `_index.json`; it never rewrites the unselected +records (a partial run used to stub the whole corpus). A parser change that +touches many records still wants a full run. | Script | Reads | Writes | |---|---|---| @@ -4772,3 +4776,15 @@ static link layer (all in [scripts/_lib/map_template.py](scripts/_lib/map_templa benign same-value dups without failing); `tests/test_no_duplicate_keys.py` runs it under `pytest`. Run `.venv/bin/python scripts/audit_dup_keys.py` (or `pytest`) after editing `grape_lexicon.py` or any other large lookup table. +## Analytics + +Self-hosted Plausible (site id `openwinemap.com`); the snippet is in +`_TEMPLATE`, custom events go through `track()` in +[scripts/_lib/assets/app.js](scripts/_lib/assets/app.js). The event/prop +reference, the goal-configuration recipe (events are stored but invisible +until configured as goals — retroactively), and the known reading artefacts +(replaceState opens are not pageviews; page-load opens are not tracked; +`Appellation Viewed.slug` is the stack focus, split by `via`) live in +[docs/analytics.md](docs/analytics.md). Keep that table in sync when adding +or renaming a `track()` call, and never commit an API key. + diff --git a/CURATOR_TODO.md b/CURATOR_TODO.md index b4b3ee0..9823873 100644 --- a/CURATOR_TODO.md +++ b/CURATOR_TODO.md @@ -376,6 +376,18 @@ Shadow-report findings (`raw/inao/register/shadow-report.md`, 466 parents): Open queue — ❌ **2 appellations INAO still publishes but the EU register has struck off.** The resolver refuses to bind a `Cancelled` GI on its own (a withdrawn registration cannot be an appellation's current specification), so + + **2026-09-11 (Pouilly-Loché aire)** — the analytics surfaced a 0.4 km² + AOC drawn across all of Burgundy in simple mode: the 2024 PNOCDC writes + `1 - Aire géographique` (no degree sign) and defines the aire as a + sentence ("territoire de la commune de Mâcon"), so stage 02 scanned the + whole section and recorded the *aire de proximité* (366 communes) as the + aire. Fixed in `extract_aire` (degree-less block headers + sentence-form + aires; corpus-wide, 58 single-commune AOCs — Meursault, Pommard, the + Vosne-Romanée and Gevrey grands crus, Barsac, Cornas, Gigondas … — gained + a previously empty aire) and guarded + in stage 04 (`[villages-guard]`). The short `lien` (255 chars) is still + the register re-source candidate above. both sit in `raw/inao/register/unresolved.json` awaiting a curator call. Both already have a working BO Agri cahier, so nothing is missing from the corpus — what needs deciding is whether SIQO is stale (the mirror image of the @@ -3152,6 +3164,22 @@ full evidence in [tmp/vivc-ambiguous-research-results.md](tmp/vivc-ambiguous-res ``` avgoustiatis→801 kanella→16124 kontokladi→6395 kotsifali→6446 koutsoubeli→6463 mavrotragano→40210 skiadopoulo→11849 thrapsathiri→12428 +## Pipeline — grape canonical ranking depends on the corpus on disk + +**2026-09-11** — `_vivc_canonical_by_id` (scripts/_lib/grape_entity.py) +picks, among VIVC by-slug files sharing a vivc_id, the slug present in the +extracted corpora on disk (then the most frequent). That makes +`raw/inao/cahier-extracted/` an implicit input of every stage-02 / stage-04 +run and the ranking self-reinforcing: a stage-02 run started on a damaged +FR corpus wrote `corvo` for aubun, `rodo` for mondeuse, `araignan` for +picardan, `livornese-bianca` for rolle, `graciano` for morrastel … across +190 FR records, and later runs kept them. Recovered by seeding the FR +records' grape lists from the last good build and re-running (see the +session memory). To do: pin the FR-canonical slugs explicitly (a checked-in +vivc_id → canonical table, or make `GRAPE_ALIAS` the first tiebreaker) so +the choice no longer depends on what happens to be on disk, and add a +stage-04 assertion comparing the principal-slug set against the previous +build's blob. vertzami-lefko→13013 bratkovina→1660 debit→10423 draganela→21070 grk→5066 vugava→13184 zadarka→13365 zlahtina→22843 modra-kosovina→24493 muskat-zuti→8056 svrdlovina-crna→15638 trbljan→8075 zumic→24915 diff --git a/docs/analytics.md b/docs/analytics.md new file mode 100644 index 0000000..d04a20f --- /dev/null +++ b/docs/analytics.md @@ -0,0 +1,83 @@ +# Analytics — Plausible events, goals and how to read the numbers + +The map ships a self-hosted [Plausible](https://plausible.io/) snippet +(`analytics.dev.devloed.com`, injected by `_TEMPLATE` in +[scripts/_lib/map_template.py](../scripts/_lib/map_template.py)); the site id +is **`openwinemap.com`** (Plausible strips the `www.`). Custom events are sent +through the `track(name, props)` helper in +[scripts/_lib/assets/app.js](../scripts/_lib/assets/app.js), which no-ops +when the script is blocked. Every prop is a bounded slug vocabulary — never +raw user text — so breakdowns stay useful and nothing personal can leak. + +## Events sent by app.js + +| Event | Props | Fired when | +|---|---|---| +| `Appellation Viewed` | `slug`, `country`, `kind`, `region`, `stacked`, `stack_size`, `via`, `locale` | the detail panel opens or the stack focus changes. `via` ∈ `map` (click on the map), `cycle` (re-click cycling a stack), `facet`, `omnisearch`, `panel-link` (parent/child link inside a card). **Not** fired for the page-load open of a `//` landing (the pageview already records it) nor for the localStorage restore. | +| `Appellation Opened` | `slug`, `via` (`facet` / `omnisearch`), `locale` | an explicit pick — the cleanest interest signal | +| `Filter Applied` | `facet`, `value`, `locale` | a facet checkbox / chip / country / region / appellation filter changes | +| `Filters Reset` | `locale` | the reset button | +| `Kind Toggled` | `kind` (`igp` / `spirits`), `enabled`, `via` (`reveal-hint`, optional), `locale` | the IGP / spirits switches | +| `View Mode Switched` | `mode` (`simple` / `advanced`), `locale` | the mode toggle | +| `Grape Scope Toggled` | `scope` (`main` / `all`), `locale` | principal-only vs all grapes | +| `Omnisearch Used` | `result_count`, `had_match`, `groups` (a/g/r/s), `query_len`, `locale` | 1 s after typing stops in the omnisearch | +| `Omnisearch Result Picked` | `type` (`grape` / `region` / `style` / `classification`), `locale` | a non-appellation suggestion is picked (appellation picks fire `Appellation Opened`) | +| `Search Used` | `result_count`, `had_match`, `query_len`, `locale` | the legacy sidebar search (superseded by the omnisearch in June 2026) | +| `Theme Changed` | `theme`, `locale` | light / dark toggle | +| `Feedback Clicked` | `channel` (`github` / `email`), `locale` | a link tagged `data-feedback` in the sidebar disclaimer or the About dialog | +| `Outbound Link: Click` | `url` | Plausible's own outbound-link tracking (source PDFs, Wikipedia, interprofessions, GitHub) | + +## Goals — what the dashboard and the Stats API can see + +Plausible stores every custom event but only **shows** the ones configured as +a goal (Site settings → Goals → *Add goal* → *Custom event*, exact name). +Adding a goal is retroactive — history appears immediately. Configured on +2026-09-11: `Appellation Viewed`, `Filter Applied`, `Outbound Link: Click`, +`View Mode Switched`, `Kind Toggled`, `Filters Reset`, `Search Used`. + +**Still to add** (sent since June 2026 but invisible until then): + +- `Appellation Opened` +- `Omnisearch Used` +- `Omnisearch Result Picked` +- `Theme Changed` +- `Grape Scope Toggled` +- `Feedback Clicked` (new, 2026-09) + +Until `Omnisearch Used` is a goal, search usage is unmeasured — `Search Used` +went to zero when the omnisearch replaced the sidebar search on 2026-06-19. + +## Reading the numbers — artefacts to keep in mind + +- **Pageviews on entity paths are entries only.** Opening an appellation + rewrites the URL with `history.replaceState`, which Plausible does not count + as a pageview (by design — panel opens are not page loads). `/en/bourgogne` + showing 85 visitors and 10 pageviews means 10 landings and 85 people whose + panel was on Bourgogne at some point. Use the `Appellation Viewed` slug + breakdown, not the Pages report, for what people look at. +- **Bounce on entity landings was ~0 by construction until 2026-09-11**: the + page-load open fired `Appellation Viewed`, and any second event ends a + bounce. From this build on, a landing that only reads the SSR page counts + as a bounce again, so expect the organic bounce rate to rise from ~8 % to + something honest. +- **Empty entry page + 0 pageviews** (≈ 7 % of visits) are map tabs resumed + after the 30-minute session timeout; they show as *Direct*. Real, engaged + sessions — not a bug. +- **`Appellation Viewed.slug` is the stack focus**, i.e. the smallest + bounding box under the click, so an umbrella appellation whose aire equals + a famous one gets the credit (Coteaux Champenois ≡ Champagne). Filter on + `via` = `facet` / `omnisearch` — or use `Appellation Opened` — for intent. +- A wrongly-huge simple-mode polygon shows up in this breakdown before it + shows up anywhere else (Pouilly-Loché, 2026-09: a 0.4 km² AOC drawn + across all of Burgundy by a stage-02 aire parse miss). Stage 04's + `[villages-guard]` log line now flags the pattern; the analytics is the + second line of defence. + +## Querying + +The Stats API v2 (`POST /api/v2/query`, `Authorization: Bearer `) covers +aggregates, time series and breakdowns by any prop or visit dimension; no DB +tunnel is needed for insights. Keys are created per session in Plausible's +settings and revoked afterwards — never commit or store one. A ClickHouse +tunnel (port 8123) is only worth it for session-sequence questions +("searched, then opened?") that the API cannot express. diff --git a/scripts/02_extract_cahiers.py b/scripts/02_extract_cahiers.py index 80b413a..c10e1a1 100644 --- a/scripts/02_extract_cahiers.py +++ b/scripts/02_extract_cahiers.py @@ -117,9 +117,35 @@ + r")" + r"\s*(?:\(\d+\))?" + r"(?P(?:[^:\n]*\n?){0,4}?):" - + r"(?P[^\n]*(?:\n(?!\s*\n|\s*-?\s*(?i:D[ée]partement|Dans\s+(?:le|les)\s+d[ée]partement)|\s*\d°|\s*[IVX]+\s*\.\s*-)[^\n]*)*)" + + r"(?P[^\n]*(?:\n(?!\s*\n|\s*-?\s*(?i:D[ée]partement|Dans\s+(?:le|les)\s+d[ée]partement)|\s*\d[ \t]*(?:°|[-–][ \t]*[A-Za-zÀ-ÿ])|\s*[IVX]+\s*\.\s*-)[^\n]*)*)" ) DEPT_HEADER_RE = re.compile(_DEPT_HEADER_PATTERN, re.MULTILINE) +# Sub-block headers inside section IV: "1° - Aire géographique", +# "1°- Aire parcellaire délimitée", plus the degree-less "1 - Aire +# géographique" / "1) Aire …" forms the 2024 PNOCDC republications use +# (Pouilly-Loché). The marker (°, dash or paren) is mandatory so a body +# line that merely starts with a digit ("3 communes …") never opens a +# block. Without the split the whole section is scanned and the aire de +# proximité immédiate list is taken as the aire géographique. +# Intra-line gaps are [ \t] only: a page-number line (" 2") followed by a +# form feed and "- Département du Rhône : …" must not glue into one header. +_AIRE_BLOCK_HEADER_PATTERN = ( + r"^[ \t\x0c]*(\d[ \t]*(?:°[ \t]*[-–)]?|[-–]|\))[ \t]*[A-Za-zÀ-ÿ][^\n]*)$" +) +# Sentence-form aire, used when a block carries no "Département de X :" +# list: "… sont assurés sur le territoire de la commune de Mâcon du +# département de Saône-et-Loire" / "des communes de A, B et C du +# département de l'Yonne". One match per (commune list, département). +_AIRE_SENTENCE_PATTERN = ( + r"(?:territoire\s+)?(?i:de\s+la\s+commune|des\s+communes)\s+(?i:de\s+|d['’]\s*)" + r"(?P[^:;.]+?)\s+" + r"(?i:du|dans\s+le)\s+(?i:d[ée]partement)\s+(?i:" + _ART + r")" + r"(?P" + r"[A-ZÀÂÄÉÈÊËÎÏÔÖÙÛÜŸ][\wÀ-ÿ'’]*(?:-[\wÀ-ÿ'’]+)*" + r"(?:\s+[A-ZÀÂÄÉÈÊËÎÏÔÖÙÛÜŸ][\wÀ-ÿ'’]*(?:-[\wÀ-ÿ'’]+)*)*" + r")" +) +AIRE_SENTENCE_RE = re.compile(_AIRE_SENTENCE_PATTERN) COG_YEAR_RE = re.compile(r"code officiel g[ée]ographique de l['’]ann[ée]e\s+(\d{4})") @@ -746,16 +772,33 @@ def parse_communes(field: str) -> list[str]: return out +def _clean_commune_tokens(tokens: list[str]) -> list[str]: + """Drop the prose that rides along with a sentence-form commune list. + + A commune name starts with a capital and never contains a sentence + break, but the sentence form leaks asides into the split tokens: + "sur la base du code officiel géographique …" (Barsac), "située" + (Loupiac: "la commune de Loupiac, située dans le département"). Applied + to the sentence path only — the list form ("Département de X : …") + legitimately carries lowercase-initial tokens in IGP layouts + ("l'ensemble des communes …"), so it is left as it was. + """ + out: list[str] = [] + for c in tokens: + if ". " in c: + c = c.split(". ", 1)[0].strip() + if not c or not c[:1].isupper() or "code officiel" in c.lower(): + continue + out.append(c) + return out + + def extract_aire(section_iv: str) -> dict: """Parse section IV. Returns geographique/proximite_immediate commune lists.""" cog_match = COG_YEAR_RE.search(section_iv) cog_year = int(cog_match.group(1)) if cog_match else None - blocks = re.split( - r"^\s*(\d°\s*[-–]?\s*[A-Za-zÀ-ÿ][^\n]*)$", - section_iv, - flags=re.MULTILINE, - ) + blocks = re.split(_AIRE_BLOCK_HEADER_PATTERN, section_iv, flags=re.MULTILINE) # blocks: [pre, header1, body1, header2, body2, ...] by_block: dict[str, str] = {} for i in range(1, len(blocks) - 1, 2): @@ -773,6 +816,12 @@ def by_dept(text: str) -> dict[str, list[str]]: communes = parse_communes(communes_raw) if communes: result[dept].extend(communes) + if not result: + for m in AIRE_SENTENCE_RE.finditer(text): + dept = re.sub(r"\s+", " ", m.group("dept")).strip(" '’.,:") + communes = _clean_commune_tokens(parse_communes(m.group("communes"))) + if communes: + result[dept].extend(communes) return dict(result) aire_geo_text = next( @@ -1581,9 +1630,22 @@ def main() -> int: dgc_emitted += 1 set_pliego_context(None) - stubs = emit_stub_records( - siqo_denoms, siqo_categories, manifest, index, slug_map, OUT_DIR - ) + if args.only or args.limit: + # A partial run must leave every unselected record untouched. The + # stub pass walks the whole SIQO referentiel and would rewrite + # every record outside the selection as `no-extract` (it once + # stubbed 1,131 records on a 61-name `--only` run), and the index + # would shrink to the selection — so skip the stub pass and merge + # the fresh entries into the index on disk instead. + stubs = 0 + if INDEX_PATH.exists(): + merged = json.loads(INDEX_PATH.read_text(encoding="utf-8")) + merged.update(index) + index = merged + else: + stubs = emit_stub_records( + siqo_denoms, siqo_categories, manifest, index, slug_map, OUT_DIR + ) INDEX_PATH.write_text(json.dumps(index, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8") unknowns_path = ROOT / "raw" / "inao" / "extraction-unknowns.json" diff --git a/scripts/04_build_maps.py b/scripts/04_build_maps.py index f711ab7..510f445 100644 --- a/scripts/04_build_maps.py +++ b/scripts/04_build_maps.py @@ -597,6 +597,19 @@ def communes_containing(needle, insee_idx: dict[str, dict]) -> set[str]: """ px0, py0, px1, py1 = needle.bounds out: set[str] = set() +# Villages (simple-mode) polygon vs parcellaire bbox ratio above which a +# cahier-text commune union is treated as an extraction artefact and +# narrowed to parcel-bearing communes. Corpus median is ~2x; legitimate +# single-commune aires around tiny crus reach ~1000x but never pass the +# commune-count gate. Pouilly-Loché's bad aire was ~44,000x. +VILLAGES_BBOX_GUARD_RATIO = 20.0 + + +def _bbox_area(g) -> float: + minx, miny, maxx, maxy = g.bounds + return float((maxx - minx) * (maxy - miny)) + + for code, gd in insee_idx.items(): cx0, cy0, cx1, cy1 = _geojson_bounds(gd) if cx1 < px0 or cx0 > px1 or cy1 < py0 or cy0 > py1: @@ -2213,6 +2226,42 @@ def main() -> int: v_geom = clip_res.geom elif v_geom is not None and not v_geom.is_empty: v_geom = geom_overrides.clip(record["slug"], v_geom, geom_source).geom + # Guard: a cahier-text aire that dwarfs the parcellaire polygon is + # an extraction artefact (an aire de proximité list read as the + # aire — Pouilly-Loché's 2024 cahier drew the AOC across all of + # Burgundy in simple mode), not a production area. Narrow the + # text-derived communes to those holding parcels, exactly as the + # aires-CSV path does, and say so loudly. Dormant for correct + # records: a single-commune aire around a tiny cru never trips + # the count gate, and a multi-commune aire whose communes all + # hold parcels keeps every commune. + if ( + v_source == "communes" + and geom_source == "parcellaire" + and v_geom is not None + and not v_geom.is_empty + and geom is not None + and not geom.is_empty + and v_stats.get("matched", 0) > 3 + and _bbox_area(v_geom) > VILLAGES_BBOX_GUARD_RATIO * _bbox_area(geom) + ): + text_codes = cahier_insee(record, commune_idx) + narrowed = { + c for c in text_codes + if c in insee_idx and shape(insee_idx[c]).intersects(geom) + } + if narrowed and len(narrowed) < len(text_codes): + n_geom, n_stats = union_from_insee(narrowed, insee_idx) + if n_geom is not None and not n_geom.is_empty: + ratio = _bbox_area(v_geom) / max(_bbox_area(geom), 1e-12) + print( + f"[villages-guard] {record['slug']}: cahier-text aire " + f"({len(text_codes)} communes) spans {ratio:.0f}x the " + f"parcellaire bbox — narrowed to {len(narrowed)} " + f"parcel-bearing commune(s); check the stage-02 aire", + file=sys.stderr, + ) + v_geom, v_stats = n_geom, n_stats if clip_res.dropped or clip_res.stale: geom_clip_results.append(clip_res) diff --git a/scripts/_lib/assets/app.js b/scripts/_lib/assets/app.js index 8c121f2..db54b5d 100644 --- a/scripts/_lib/assets/app.js +++ b/scripts/_lib/assets/app.js @@ -578,6 +578,12 @@ } }; a.addEventListener('mousedown', arm); + // Which feedback channel gets used. The GitHub link is an outbound click + // Plausible already counts, but clicks there produced no issues, so the + // channel split (github vs e-mail) is the number that matters. + document.querySelectorAll('a[data-feedback]').forEach(a => { + a.addEventListener('click', () => track('Feedback Clicked', { channel: a.dataset.feedback, locale: LANG })); + }); a.addEventListener('focus', arm); a.addEventListener('touchstart', arm, { passive: true }); a.addEventListener('click', () => { @@ -1075,7 +1081,7 @@ lastPanelTrigger = btn || (label && label.querySelector('.open-aoc')) || null; lastStackKey = slug; stackFocusIndex = 0; - renderPanelStack([slug], 0); + renderPanelStack([slug], 0, undefined, 'facet'); track('Appellation Opened', { slug: slug, via: 'facet', locale: LANG }); const b = (viewMode === 'simple' && AOCS[slug].bbox_villages) ? AOCS[slug].bbox_villages : AOCS[slug].bbox; if (b && typeof map.fitBounds === 'function') { @@ -1672,7 +1678,7 @@ lastPanelTrigger = document.getElementById('omni'); lastStackKey = key; stackFocusIndex = 0; - renderPanelStack([key], 0); + renderPanelStack([key], 0, undefined, 'omnisearch'); track('Appellation Opened', { slug: key, via: 'omnisearch', locale: LANG }); const b = (viewMode === 'simple' && AOCS[key].bbox_villages) ? AOCS[key].bbox_villages : AOCS[key].bbox; if (b && typeof map.fitBounds === 'function') map.fitBounds([[b[0], b[1]], [b[2], b[3]]], { padding: 40, maxZoom: 11, duration: 500 }); @@ -2416,7 +2422,7 @@ + ``; } - function renderPanelStack(slugs, focusIndex, doTrack) { + function renderPanelStack(slugs, focusIndex, doTrack, via) { if (!slugs.length) return; const sorted = slugs .filter(s => AOCS[s]) @@ -2450,8 +2456,15 @@ } // Popularity signal: the appellation brought to the front of the stack. // doTrack is suppressed for the localStorage restore (fires on every - // reload / language switch — not a fresh view). - if (doTrack !== false) { + // reload / language switch — not a fresh view). The page-load open of a + // // landing is not tracked either: the pageview already + // records that slug, and a custom event fired on load made every entity + // landing a non-bounce by construction and let a session start with a + // custom event (empty entry page) whenever the pageview was deferred. + // `via` separates a map click from stack cycling and from the explicit + // facet / omnisearch / in-panel opens, so the slug breakdown reads per + // intent instead of as one popularity list. + if (doTrack !== false && via !== 'landing') { const focusSlug = ordered[0]; const fr = AOCS[focusSlug]; if (fr) { @@ -2531,6 +2544,8 @@ p = (p.indexOf(SLUG_BASE) === 0) ? p.slice(SLUG_BASE.length) : p.replace(/^\//, ''); p = p.replace(/\/+$/, '').split('/')[0]; try { p = decodeURIComponent(p); } catch (e) {} + stack_size: String(sorted.length), + via: via || 'map', return p || null; } function setAocPath(slug) { @@ -2578,7 +2593,7 @@ if (urlSlug && AOCS[urlSlug]) { lastStackKey = urlSlug; stackFocusIndex = 0; - renderPanelStack([urlSlug], 0); + renderPanelStack([urlSlug], 0, undefined, 'landing'); // Frame the shared appellation, but only when the link carries no // explicit camera hash (respect a co-shared #zoom/lat/lon). Use the // page-entry snapshot, not the live hash — maplibre has already written @@ -2736,7 +2751,7 @@ lastPanelTrigger = null; lastStackKey = ''; stackFocusIndex = 0; - renderPanelStack([slug]); + renderPanelStack([slug], 0, undefined, 'panel-link'); } }); @@ -2800,7 +2815,7 @@ __OWM_source_block__ stackFocusIndex = 0; } lastPanelTrigger = null; - renderPanelStack(slugs, stackFocusIndex); + renderPanelStack(slugs, stackFocusIndex, undefined, via); }); // Re-apply feature-state for any selection restored from localStorage @@ -2811,3 +2826,5 @@ __OWM_source_block__ applyFilter(); updateStatus(); }); + let via = 'map'; + via = 'cycle'; diff --git a/scripts/_lib/map_template.py b/scripts/_lib/map_template.py index 9489a70..364329e 100644 --- a/scripts/_lib/map_template.py +++ b/scripts/_lib/map_template.py @@ -460,13 +460,16 @@ def _ext_link(url: str, label: str) -> str: def _feedback_email_anchor(label: str) -> str: return ( - f'' ) def _build_sidebar_disclaimer(labels: dict[str, str]) -> str: - issue = _ext_link(_GITHUB_NEW_ISSUE_URL, labels["feedback_issue_label"]) + issue = ( + f'{labels["feedback_issue_label"]}' + ) email = _feedback_email_anchor(labels["feedback_email_label"]) return ( f'