Skip to content

Commit ac11493

Browse files
beetlebugorgclaude
andcommitted
feat(web): display Calibration tab + fix the 1.333x feature-size baseline
Whole-chart features rendered ~1.333x too big: the baker emits sizes at the 1/96" CSS reference pixel (0.26458 mm, = portrayal.DefaultPxPerSymbolUnit), but the client's physical-size multiplier assumed the 1/72" typographic point (0.35278 mm) and scaled everything by 0.35278/pxPitch. Align the client reference to 0.26458 mm so the math is correct (and it explains why changing the BAKER constant was a render no-op — the mismatch was client-side). True physical size still needs the screen's real CSS-pixel pitch, which the browser won't reveal — so add a "Calibration" settings tab (its own left-rail section, a contribution like the Advanced tab): a reference box that should be exactly 5 mm (the S-52 CHKSYM check box). Measure it with a ruler, enter the value, and it sets pxPitch = pitch × measured/5 (physical size ∝ 1/pitch), rescaling symbols/lines/text to true size and persisting via setPxPitch (localStorage + server settings). Reset returns to the CSS-reference default. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fe748be commit ac11493

3 files changed

Lines changed: 95 additions & 6 deletions

File tree

web/src/chart-canvas/chart-canvas.mjs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,17 @@ import {
6767
// the layer builder AND this element's registerPattern — and imported back here.
6868
import { buildChartLayers, PAT_PREFIX } from "./chart-style.mjs";
6969

70-
const FEATURE_SCALE = 0.01 / 0.35278;
70+
const FEATURE_SCALE = 0.01 / 0.26458;
7171
// The baker emits feature pixel sizes (icon `scale`, `width_px`, `font_size_px`,
72-
// pattern raster) as if 1 px = 1 typographic point = 0.35278 mm (72 DPI). To render
73-
// at TRUE physical size we multiply every size by 0.35278/pxPitch (see _scaleSizes
74-
// in chart-style.mjs / _featureSizeScale below). On the default CSS pixel (0.2645 mm)
75-
// that is ≈1.333×; a calibrated screen pitch makes it exact.
76-
const BAKED_FEATURE_PITCH_MM = 0.35278;
72+
// pattern raster) at the 1/96-inch CSS reference pixel = 0.26458 mm — the SAME
73+
// reference as portrayal.DefaultPxPerSymbolUnit (0.01/0.26458). To render at TRUE
74+
// physical size we multiply every size by 0.26458/pxPitch (see _scaleSizes in
75+
// chart-style.mjs / _featureSizeScale below): on a screen whose real CSS-pixel pitch
76+
// is 0.26458 mm that is 1×, and the Calibration panel sets pxPitch from a ruler
77+
// measurement of the 5 mm check box for any other screen. (Was 0.35278, the 1/72"
78+
// typographic point — a reference the baker does NOT use, which rendered the whole
79+
// chart 0.35278/0.26458 = 1.333× too big.)
80+
const BAKED_FEATURE_PITCH_MM = 0.26458;
7781
// Linear (constant-velocity) easing for the follow camera — see updateFollow. The
7882
// default ease-in/out would stall at each fix boundary, reading as a step.
7983
const LINEAR = (t) => t;

web/src/chartplotter.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import "./plugins/chart-library.mjs"; // defines <chart-library> (the "Charts li
2222
import "./plugins/settings-dialog.mjs"; // defines <settings-dialog> (the settings panel host)
2323
import { SettingsRegistry } from "./core/settings-registry.mjs"; // contribution registry for the settings panel
2424
import { coreSettingsContributions } from "./core/core-settings.mjs"; // the app's own display settings as contributions
25+
import { calibrationContribution } from "./plugins/calibration.mjs"; // "Calibration" tab — ruler-measure the 5 mm box → true physical scale
2526
import { DevTools } from "./plugins/dev-tools.mjs"; // the slim contributed Advanced-tab dev tools (rebake + feature inspector)
2627
import { ConnectionsController } from "./plugins/connections.mjs"; // NMEA0183 data-source manager (Connections tab)
2728
import { VesselStateStore } from "./data/vessel-state-store.mjs"; // live NMEA0183 vessel state (own-ship/AIS/HUD feed)
@@ -371,6 +372,8 @@ export class ChartPlotter extends HTMLElement {
371372
if (this._widget && c.id === "core-advanced") continue;
372373
this._settingsRegistry.register(c);
373374
}
375+
// Display calibration (ruler-measure the 5 mm check box → true physical scale).
376+
this._settingsRegistry.register(calibrationContribution(this));
374377
this._settingsDlg = this.shadowRoot.getElementById("settings-dlg");
375378
if (this._settingsDlg) this._settingsDlg.configure({ registry: this._settingsRegistry });
376379

web/src/plugins/calibration.mjs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// calibration.mjs — a "Calibration" settings tab that makes the chart render at TRUE
2+
// physical size on THIS screen. S-52 features are drawn at their real millimetre size
3+
// (icons, line weights, text), which only works if the app knows the screen's actual
4+
// CSS-pixel pitch. We can't read that from the browser, so the user calibrates it the
5+
// ECDIS way: a reference box that should be exactly 5 mm wide (the S-52 CHKSYM check
6+
// box), measured with a ruler. They enter what they actually measure and the whole
7+
// chart rescales.
8+
//
9+
// Registers itself as a settings contribution with a render(host) custom slot, like
10+
// the Advanced/dev-tools tab. Pure UI: the only app coupling is reading the current
11+
// pitch (app._pxPitch) and calling app.setPxPitch(mm), which persists + re-renders.
12+
13+
import { DEFAULT_PX_PITCH_MM, clampPxPitch } from "../lib/util.mjs";
14+
15+
const REF_MM = 5; // the S-52 size-check box (CHKSYM01) is 5 mm × 5 mm
16+
17+
// A feature P mm wide renders at P/pxPitch CSS px (chart-canvas _featureSizeScale), so
18+
// the reference box uses the same mapping — it's a faithful proxy for a 5 mm feature.
19+
const boxPx = (pitch) => Math.max(1, Math.round(REF_MM / pitch));
20+
21+
export function calibrationContribution(app) {
22+
return {
23+
id: "calibration",
24+
tab: { id: "calibration", label: "Calibration" },
25+
order: 4,
26+
render: (host) => renderCalibration(host, app),
27+
};
28+
}
29+
30+
function renderCalibration(host, app) {
31+
const calibrated = typeof app._pxPitch === "number" && app._pxPitch > 0;
32+
const pitch = clampPxPitch(calibrated ? app._pxPitch : undefined);
33+
const px = boxPx(pitch);
34+
host.innerHTML = `
35+
<style>
36+
.cal { max-width: 30rem; }
37+
.cal__intro { font-size: .9rem; line-height: 1.4; margin: 0 0 1rem; }
38+
.cal__row { display: flex; gap: 1.25rem; align-items: flex-start; flex-wrap: wrap; }
39+
.cal__box { background: #1b1b1b; outline: 1px solid #888; flex: 0 0 auto; }
40+
.cal__form { display: flex; flex-direction: column; gap: .6rem; }
41+
.cal__label { font-size: .85rem; display: flex; flex-direction: column; gap: .25rem; }
42+
.cal__in { display: inline-flex; align-items: baseline; gap: .35rem; }
43+
.cal__in input { width: 5rem; }
44+
.cal__btns { display: flex; gap: .5rem; }
45+
.cal__cur { font-size: .78rem; opacity: .7; margin: .25rem 0 0; }
46+
.cal__hint { font-size: .78rem; opacity: .7; margin: .5rem 0 0; }
47+
</style>
48+
<div class="cal">
49+
<p class="cal__intro">Make the chart match real-world size. Hold a ruler to your screen and measure the box — it should be exactly <b>${REF_MM} mm</b> across. Enter what you actually measure and the whole chart (symbols, line weights, text) rescales to true physical size.</p>
50+
<div class="cal__row">
51+
<div class="cal__box" style="width:${px}px;height:${px}px"></div>
52+
<div class="cal__form">
53+
<label class="cal__label">Measured width
54+
<span class="cal__in"><input id="cal-mm" type="number" step="0.1" min="1" value="${REF_MM.toFixed(1)}"> mm</span>
55+
</label>
56+
<div class="cal__btns">
57+
<button id="cal-apply" type="button">Apply</button>
58+
<button id="cal-reset" type="button">Reset</button>
59+
</div>
60+
<p class="cal__cur">Pixel pitch: <b>${pitch.toFixed(4)} mm</b>${calibrated ? "" : " (default — uncalibrated)"}</p>
61+
</div>
62+
</div>
63+
<p class="cal__hint">Tip: a wider measurement zooms the chart down, a narrower one up. Apply, then re-measure to confirm the box reads 5 mm.</p>
64+
</div>`;
65+
66+
const mmInput = host.querySelector("#cal-mm");
67+
host.querySelector("#cal-apply").addEventListener("click", () => {
68+
const measured = parseFloat(mmInput.value);
69+
if (!(measured > 0)) return;
70+
const cur = clampPxPitch(typeof app._pxPitch === "number" && app._pxPitch > 0 ? app._pxPitch : undefined);
71+
// Physical size ∝ 1/pitch, so to turn the measured size into REF_MM scale the
72+
// pitch by measured/REF (clampPxPitch in setPxPitch guards absurd values).
73+
app.setPxPitch(cur * (measured / REF_MM));
74+
renderCalibration(host, app); // redraw at the new calibration to verify
75+
});
76+
host.querySelector("#cal-reset").addEventListener("click", () => {
77+
app.setPxPitch(undefined); // back to the CSS-reference default
78+
renderCalibration(host, app);
79+
});
80+
}
81+
82+
export { DEFAULT_PX_PITCH_MM };

0 commit comments

Comments
 (0)