Skip to content

Commit 7bd2ccb

Browse files
beetlebugorgclaude
andcommitted
feat(web): centered scale/zoom/position readout + setView() API
Make the status readout its own fixed-size pill pinned to the bottom middle, and add the map centre's lat/lon to it (degrees-decimal-minutes). Every field is fixed-width with tabular figures so the pill never resizes or shifts as the scale, zoom, or centre coordinates update — panning only changes the position field. The readout moves out of #statusbar, which now hosts just the bake-status chip and hides itself when that chip is hidden. Add a public setView({lat,lng,scale|zoom,...}) on <chart-plotter> (and a shell delegate on <chart-plotter-app>) to open the viewport on a position at a target paper scale. `scale` (1:N) converts to the web-Mercator zoom at the target latitude via zoomForScale(), the exact inverse of the readout's scaleDenom, so the requested scale lands precisely. Latitude is clamped to the Mercator limit; the map's scale-floor max-zoom still caps over-fine requests. Routing through jumpTo/easeTo lets the existing move handlers update the readout and persist the view automatically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6b4f939 commit 7bd2ccb

2 files changed

Lines changed: 87 additions & 9 deletions

File tree

web/chartplotter-app.mjs

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -698,12 +698,15 @@ export class ChartPlotterApp extends HTMLElement {
698698
const n = this._installed.size;
699699
label = `${n} chart${n !== 1 ? "s" : ""}` + (failed ? ` · ${failed} failed` : "");
700700
} else {
701-
el.hidden = true; el.classList.remove("busy"); return;
701+
el.hidden = true; el.classList.remove("busy");
702+
const sb = root.getElementById("statusbar"); if (sb) sb.hidden = true;
703+
return;
702704
}
703705
txt.textContent = label;
704706
el.classList.toggle("busy", busy);
705707
el.classList.toggle("has-fail", failed > 0);
706708
el.hidden = false;
709+
const sb = root.getElementById("statusbar"); if (sb) sb.hidden = false;
707710
}
708711

709712
// Subtle "loading more while data is shown" cue: a thin indeterminate bar at the
@@ -954,7 +957,8 @@ export class ChartPlotterApp extends HTMLElement {
954957
`<span class="hud-main"><span class="hud-dot" style="background:${BAND_COLOR[band]}"></span>` +
955958
`<span class="hud-band">${BAND_LABEL[band]}</span><span class="hud-sep">·</span>` +
956959
`<span class="hud-scale">1:${fmtScale(scaleDenom(z, c.lat))}</span><span class="hud-sep">·</span>` +
957-
`<span class="hud-z">z${z.toFixed(1)}</span></span>`;
960+
`<span class="hud-z">z${z.toFixed(1)}</span><span class="hud-sep">·</span>` +
961+
`<span class="hud-coord">${fmtLatLon(c.lat, c.lng)}</span></span>`;
958962
}
959963

960964

@@ -1512,6 +1516,16 @@ export class ChartPlotterApp extends HTMLElement {
15121516
this._clearTaskSoon(1200);
15131517
}
15141518

1519+
// Public: open the viewport on a position at a target paper scale or zoom — a
1520+
// shell-level entry to the <chart-plotter> primitive (see its setView for the
1521+
// option shape). The map's move/moveend then update the readout and persist the
1522+
// view as if the user had navigated there. Examples:
1523+
// app.setView({ lat: 37.81, lng: -122.45, scale: 20000 }) // 1:20,000
1524+
// app.setView({ lat: 37.81, lng: -122.45, zoom: 14, animate: true })
1525+
setView(opts) {
1526+
return this._plotter ? this._plotter.setView(opts) : null;
1527+
}
1528+
15151529
saveView() {
15161530
// Don't persist the Charts-mode selection framing (the zoomed-out world view):
15171531
// a refresh should resume where the user was actually looking at charts, not
@@ -3323,14 +3337,26 @@ export class ChartPlotterApp extends HTMLElement {
33233337
.csp-stat { flex:none; font-weight:600; font-size:11px; }
33243338
.csp-queued { color:#9aa7b4; } .csp-loading { color:#d9892b; } .csp-ready { color:#2e9b57; } .csp-failed { color:#cf3b3b; }
33253339
.csp-empty { color:var(--ui-text-dim); font:500 12px/1.2 system-ui,sans-serif; padding:8px 0; }
3326-
/* The chip hugs its content; left edge is anchored, so the leading dot+band
3327-
stay put as the scale digits change (tabular figures). */
3328-
.sb-readout { flex:none; }
3340+
/* Scale/zoom/position readout — its own fixed-size pill, pinned to the
3341+
bottom-MIDDLE. Every field below has a fixed width + tabular figures so
3342+
the pill's size never changes and nothing shifts as the scale, zoom, or
3343+
centre coordinates update (panning changes only the position field). */
3344+
.sb-readout { position:absolute; left:50%; bottom:calc(var(--botbar-h) + 8px);
3345+
transform:translateX(-50%); z-index:6; box-sizing:border-box;
3346+
display:inline-flex; align-items:center; padding:5px 12px; max-width:calc(100vw - 20px);
3347+
background:color-mix(in srgb, var(--ui-surface) 82%, transparent); border:1px solid rgba(0,0,0,.06);
3348+
border-radius:11px; backdrop-filter:blur(6px);
3349+
box-shadow:inset 0 1px 2px rgba(0,0,0,.18), inset 0 -1px 0 rgba(255,255,255,.4), 0 1px 0 rgba(255,255,255,.4);
3350+
font:11px system-ui,sans-serif; color:var(--ui-text); }
3351+
.sb-readout:empty { display:none; }
33293352
.sb-readout .hud-main { display:flex; align-items:center; gap:6px;
33303353
font-weight:600; font-size:12px; white-space:nowrap; font-variant-numeric:tabular-nums; }
33313354
.sb-readout .hud-dot { width:8px; height:8px; border-radius:50%; flex:none; box-shadow:0 0 0 2px rgba(255,255,255,.6); margin-right:1px; }
3332-
.sb-readout .hud-scale { color:var(--ui-accent); }
3333-
.sb-readout .hud-z { color:var(--ui-text-dim); }
3355+
/* Fixed-width fields (left-aligned) so the pill is a constant size. */
3356+
.sb-readout .hud-band { display:inline-block; width:56px; }
3357+
.sb-readout .hud-scale { display:inline-block; width:74px; color:var(--ui-accent); }
3358+
.sb-readout .hud-z { display:inline-block; width:40px; color:var(--ui-text-dim); }
3359+
.sb-readout .hud-coord { display:inline-block; width:150px; color:var(--ui-text-dim); }
33343360
.sb-readout .hud-sep { color:var(--ui-text-faint); }
33353361
/* In-view band pills, right-aligned; each opens a cell-list popup. */
33363362
.sb-bands { display:flex; align-items:center; gap:8px; min-width:0; margin-left:auto; }
@@ -3571,13 +3597,13 @@ export class ChartPlotterApp extends HTMLElement {
35713597
}
35723598
</style>
35733599
<div id="map"></div>
3574-
<div id="statusbar">
3600+
<div id="statusbar" hidden>
35753601
<button id="bake-status" class="sb-bake" type="button" hidden title="Chart tile generation — click for per-cell status">
35763602
<span class="sb-bake-spin"></span><span class="sb-bake-txt"></span>
35773603
</button>
3578-
<div id="cov-readout" class="sb-readout"></div>
35793604
<div id="cell-status-pop" hidden></div>
35803605
</div>
3606+
<div id="cov-readout" class="sb-readout"></div>
35813607
<div id="load-bar" class="load-bar" aria-hidden="true"></div>
35823608
<div id="rail">
35833609
<div class="rail-tabs">
@@ -4035,6 +4061,22 @@ function fmtScale(d) {
40354061
return (Math.round(d / mag) * mag).toLocaleString();
40364062
}
40374063

4064+
// Map-centre position as fixed-width degrees-decimal-minutes, e.g.
4065+
// "39°27.6′N 104°39.6′W". Degrees are zero-padded (lat 2, lon 3) and minutes to
4066+
// "MM.M", so the character count never changes — with tabular figures, the
4067+
// centred status pill never reflows as you pan. Longitude is normalised to ±180.
4068+
function fmtLatLon(lat, lng) {
4069+
const dm = (v, degDigits) => {
4070+
let a = Math.abs(v);
4071+
let d = Math.floor(a);
4072+
let m = (a - d) * 60;
4073+
if (m >= 59.95) { m = 0; d += 1; } // round-up carry: 59.95′ rolls to next degree
4074+
return String(d).padStart(degDigits, "0") + "°" + m.toFixed(1).padStart(4, "0") + "′";
4075+
};
4076+
const x = ((((lng + 180) % 360) + 360) % 360) - 180; // wrap to [-180, 180)
4077+
return dm(lat, 2) + (lat >= 0 ? "N" : "S") + " " + dm(x, 3) + (x >= 0 ? "E" : "W");
4078+
}
4079+
40384080
// True when the page was opened as a shared-view link (<origin>/#share or
40394081
// ?share) — boot() then reconstructs the publisher's scene from /api/share.
40404082
function isShareUrl() {

web/chartplotter.mjs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@ const FALLBACK = "#ff00ff";
5555
const FEATURE_SCALE = 0.01 / 0.35278;
5656
const FONT = ["Noto Sans Regular"];
5757
const M_TO_FT = 3.280839895; // depth-unit conversion (metric ↔ imperial)
58+
// Web-Mercator zoom that renders a paper scale of 1:`scale` at `lat` — the
59+
// inverse of the HUD's scaleDenom (mpp = 156543.034·cos φ / 2^z; scale = mpp/
60+
// 0.00028). Latitude-dependent because a given scale is a different zoom at each
61+
// latitude. Clamped to [0,24]; the map's own max-zoom further caps over-fine views.
62+
function zoomForScale(scale, lat) {
63+
if (!(scale > 0)) return 0;
64+
const z = Math.log2(156543.03392804097 * Math.cos((lat * Math.PI) / 180) / (0.00028 * scale));
65+
return Math.max(0, Math.min(24, z));
66+
}
5867
// S-57 meta objects whose boundary draws as a region/coverage line (nautical
5968
// publication, nav-system, coverage, compilation scale). These are administrative
6069
// indicators (S-52 PresLib gives M_NPUB a line only as a pick-report hint); they
@@ -727,6 +736,33 @@ export class ChartPlotter extends HTMLElement {
727736
// Stop following and release the camera to the user.
728737
clearFollow() { this._followFix = null; this._cameraMode = "free"; }
729738

739+
// Open the viewport on a geographic position at a target paper scale. Public API.
740+
// setView({ lat, lng, scale }) — centre + 1:N scale (jump)
741+
// setView({ lat, lng, zoom }) — centre + explicit web-Mercator zoom
742+
// setView({ scale }) — restage scale, keep the centre
743+
// setView({ lat, lng, scale, animate:true, duration:800 }) — fly instead of jump
744+
// `scale` is the paper-chart denominator (1:N) and is converted to the zoom that
745+
// yields that scale at the target latitude (web-Mercator scale is latitude-
746+
// dependent), the inverse of the HUD's scale readout. `bearing`/`pitch` pass
747+
// through. Omitted fields hold their current value. Returns the resolved
748+
// { center:[lng,lat], zoom }. The map's own max-zoom (scale floor) still
749+
// clamps an over-fine request, exactly as user zoom does.
750+
setView({ lat, lng, scale, zoom, bearing, pitch, animate = false, duration = 800 } = {}) {
751+
const map = this._map;
752+
if (!map) return null;
753+
const c = map.getCenter();
754+
// Clamp to the web-Mercator latitude limit so an out-of-range lat can't drive
755+
// cos(φ) negative and yield a NaN zoom (and so the centre is itself valid).
756+
const la = Math.max(-85.051129, Math.min(85.051129, Number.isFinite(lat) ? lat : c.lat));
757+
const lo = Number.isFinite(lng) ? lng : c.lng;
758+
let z = Number.isFinite(zoom) ? zoom : (Number.isFinite(scale) ? zoomForScale(scale, la) : map.getZoom());
759+
const cam = { center: [lo, la], zoom: z };
760+
if (Number.isFinite(bearing)) cam.bearing = bearing;
761+
if (Number.isFinite(pitch)) cam.pitch = pitch;
762+
if (animate) map.easeTo({ ...cam, duration }); else map.jumpTo(cam);
763+
return { center: [lo, la], zoom: z };
764+
}
765+
730766
// Every MapLibre SourceCache backing the chart source(s). v4 had one at
731767
// map.style.sourceCaches[id]; v5 renamed that property and can hold a separate
732768
// paint + symbol cache, so duck-type any cache-shaped dict keyed by a chart

0 commit comments

Comments
 (0)