Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/engine/src/report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe("correction reporting", () => {
it("does not hide errors larger than the absolute uncertainty", () => {
// 3.1e-4 d absolute error vs 1e-6 d uncertainty: 310x the stated precision,
// so a finite interval must be quoted (~1 day per ~3200 years), not
// "within the precision of the modelled constant".
// "beyond the precision of the published value".
const c = correction(365.2425, 365.24219, 1e-6, "day", 1);
expect(c.beyondConstantPrecision).toBe(false);
expect(Number.isFinite(c.unitsPerFullError)).toBe(true);
Expand Down
2 changes: 1 addition & 1 deletion packages/engine/src/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function correction(
// absolute error, not the relative one, or a target known to 1e-6 days
// would absorb errors ~1e-6 * period instead.
if (Math.abs(errPerUnit) < uncertainty || errPerUnit === 0) {
return { unitsPerFullError: Infinity, humanInterval: "within the precision of the modelled constant",
return { unitsPerFullError: Infinity, humanInterval: "beyond the precision of the published value",
direction: errPerUnit === 0 ? "exact" : (errPerUnit < 0 ? "fast" : "slow"),
beyondConstantPrecision: Math.abs(errPerUnit) < uncertainty };
}
Expand Down
13 changes: 13 additions & 0 deletions packages/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@
name="description"
content="Compute watch gear trains for a target ratio or period. Specify a target and constraints; Involute computes the tooth counts, the resulting error, and a schematic — every figure client-side, no network."
/>
<meta property="og:type" content="website" />
<meta property="og:title" content="Involute — gear-train calculator for watchmakers" />
<meta
property="og:description"
content="Find the gear tooth counts for a watch complication — moon phase, calendar, world time — and see exactly how accurate each train is. Runs entirely in your browser."
/>
<meta property="og:url" content="https://notadev99.github.io/Involute/" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Involute — gear-train calculator for watchmakers" />
<meta
name="twitter:description"
content="Find the gear tooth counts for a watch complication and see how accurate each train is. Exact rational math, in your browser."
/>
<link
rel="icon"
href="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E%3Cg%20fill='none'%20stroke='%239a6a1c'%20stroke-width='2'%3E%3Cpath%20d='M16%203v4M16%2025v4M29%2016h-4M7%2016H3M25.2%206.8l-2.8%202.8M9.6%2022.4l-2.8%202.8M25.2%2025.2l-2.8-2.8M9.6%209.6L6.8%206.8'/%3E%3Ccircle%20cx='16'%20cy='16'%20r='7'/%3E%3Ccircle%20cx='16'%20cy='16'%20r='2.5'/%3E%3C/g%3E%3C/svg%3E"
Expand Down
6 changes: 3 additions & 3 deletions packages/web/src/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ describe("format", () => {
});
it("formats a correction interval with direction", () => {
expect(formatInterval("about 1 day every ~2.7 years", "fast")).toContain("runs fast");
expect(formatInterval("within the precision of the modelled constant", "exact")).toBe("no correction needed");
expect(formatInterval("beyond the precision of the published value", "exact")).toBe("no correction needed");
// below the constant's precision, no direction claim is printed
expect(
formatInterval("within the precision of the modelled constant", "fast", true),
).toBe("within the precision of the modelled constant");
formatInterval("beyond the precision of the published value", "fast", true),
).toBe("beyond the precision of the published value");
});
});
28 changes: 18 additions & 10 deletions packages/web/src/schematic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,19 @@ export function schematicSvg(train: GearTrain): string {
const rMaxAll = Math.max(...train.stages.flatMap((s) => [radius(s.driverTeeth), radius(s.drivenTeeth)]));
const cy = PAD + rMaxAll;
const height = cy + rMaxAll + 24;
const width = xs[xs.length - 1] + Math.max(...arborGears[k].map((g) => radius(g.teeth))) + PAD;
const naturalWidth = xs[xs.length - 1] + Math.max(...arborGears[k].map((g) => radius(g.teeth))) + PAD;

// Mesh parity only fixes the output's sense RELATIVE to the driver — no
// absolute driver direction exists anywhere in the model, so an absolute
// CW/CCW tag would be an unverifiable claim.
const dir = outputParity(train) === 1 ? "same sense as driver" : "opposite sense to driver";
const dirLabel = `output: ${dir}`;
// A short train's natural width is narrower than the direction label, which
// would clip when anchored at the right edge — so widen the viewBox to fit
// the label and centre the gear cluster inside it. (~7.2 px per char at the
// 10px uppercase, letter-spaced dir-tag.)
const width = Math.max(naturalWidth, dirLabel.length * 7.2 + PAD * 2);
const dx = (width - naturalWidth) / 2;

const nodes = arborGears.map((gearsOnArbor, i) => {
// draw the larger gear first so a concentric pinion sits on top of it
Expand All @@ -40,18 +52,14 @@ export function schematicSvg(train: GearTrain): string {
// companion labels just inside its own rim so the two never collide
const labelY = sorted.length > 1 && j === 0 ? cy - r + 14 : cy + 4;
return `
<circle class="gear-node${g.idler ? " idler" : ""}" cx="${xs[i]}" cy="${cy}" r="${r}" />
<text class="tooth-label" x="${xs[i]}" y="${labelY}" text-anchor="middle">${g.teeth}</text>
${g.idler ? `<text class="idler-tag" x="${xs[i]}" y="${cy + rMaxAll + 14}" text-anchor="middle">idler</text>` : ""}`;
<circle class="gear-node${g.idler ? " idler" : ""}" cx="${xs[i] + dx}" cy="${cy}" r="${r}" />
<text class="tooth-label" x="${xs[i] + dx}" y="${labelY}" text-anchor="middle">${g.teeth}</text>
${g.idler ? `<text class="idler-tag" x="${xs[i] + dx}" y="${cy + rMaxAll + 14}" text-anchor="middle">idler</text>` : ""}`;
}).join("");
}).join("");

const baseline = `<line class="plate-line" x1="${PAD}" y1="${cy}" x2="${width - PAD}" y2="${cy}" />`;
// Mesh parity only fixes the output's sense RELATIVE to the driver — no
// absolute driver direction exists anywhere in the model, so an absolute
// CW/CCW tag would be an unverifiable claim.
const dir = outputParity(train) === 1 ? "same sense as driver" : "opposite sense to driver";
const dirTag = `<text class="dir-tag" x="${width - PAD}" y="${Math.max(14, cy - rMaxAll - 8)}" text-anchor="end">output: ${dir}</text>`;
const baseline = `<line class="plate-line" x1="${PAD + dx}" y1="${cy}" x2="${naturalWidth - PAD + dx}" y2="${cy}" />`;
const dirTag = `<text class="dir-tag" x="${width - PAD}" y="${Math.max(14, cy - rMaxAll - 8)}" text-anchor="end">${dirLabel}</text>`;
// Describe the train for assistive tech — role="img" makes the child <text>
// presentational, so everything a sighted user reads must be in the label.
const stageWords = train.stages
Expand Down
127 changes: 112 additions & 15 deletions packages/web/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ body {
}

#app {
max-width: var(--measure);
max-width: 1120px;
margin: 0 auto;
padding: var(--space-8) var(--space-5) var(--space-10);
display: flex;
Expand Down Expand Up @@ -81,28 +81,75 @@ thead th {
font-weight: 600;
}

/* ---- Input panel ------------------------------------------------------- */
/* ---- Intro band -------------------------------------------------------- */
.masthead .intro {
margin: var(--space-3) 0 0;
max-width: 78ch;
color: var(--ink-soft);
font-size: var(--text-sm);
line-height: var(--leading-normal);
}

.masthead .intro strong {
color: var(--ink);
font-weight: 600;
}

/* ---- Input panel: two labelled groups ---------------------------------- */
/* Groups sit side by side on wide screens and stack when the row gets tight,
so the panel uses the horizontal space instead of running down the page. */
.input-panel {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: var(--space-4) var(--space-5);
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
gap: var(--space-5);
align-items: start;
}

.field-group {
margin: 0;
padding: var(--space-5);
border: 1px solid var(--hairline);
border-radius: var(--radius);
background: var(--panel);
}

.field-group > legend {
padding: 0 var(--space-2);
font-size: var(--text-md);
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ink);
}

.field-group .group-note {
margin: 0 0 var(--space-4);
color: var(--ink-soft);
font-size: var(--text-sm);
}

.group-fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: var(--space-4) var(--space-5);
}

.input-panel .field {
display: flex;
flex-direction: column;
gap: var(--space-2);
gap: var(--space-1);
min-width: 0;
}

.input-panel .target-field {
.group-fields .target-field {
grid-column: 1 / -1;
}

.input-panel .hint {
font-size: var(--text-2xs);
color: var(--ink-faint);
line-height: var(--leading-tight);
}

.input-panel input,
.input-panel select {
width: 100%;
Expand All @@ -126,18 +173,25 @@ button:focus-visible {
cursor: pointer;
}

.input-panel .custom-target[hidden] {
/* Conditional target sub-fields toggle on the hidden attribute — the specific
selector must beat `.input-panel .field { display: flex }`, so cover every
hidden descendant, not just one group. */
.input-panel [hidden] {
display: none;
}

.input-panel .custom-target,
.input-panel .custom-ratio-field,
.input-panel .going-train-field,
.input-panel .gear-range {
flex-flow: row wrap;
align-items: end;
gap: var(--space-2) var(--space-3);
}

.input-panel .custom-target label {
.input-panel .custom-target label,
.input-panel .custom-ratio-field label,
.input-panel .going-train-field label {
flex-basis: 100%;
}

Expand Down Expand Up @@ -168,11 +222,16 @@ button:focus-visible {
overflow-x: auto; /* very wide trains scroll within the plate */
}

/* Size the diagram by height, not width: a 2-gear train and a 5-gear train
get the same gear scale instead of a short train stretching to fill the
plate. Wider trains cap at the plate width (and scale down); the plate
scrolls only if one is genuinely wider than the container. */
.schematic {
display: block;
width: 100%;
height: auto;
min-width: 320px;
height: 220px;
width: auto;
max-width: 100%;
margin: 0 auto;
}

.schematic .plate-line {
Expand Down Expand Up @@ -233,7 +292,7 @@ button:focus-visible {

.results thead th {
text-align: left;
padding: var(--space-3) var(--space-4);
padding: var(--space-3);
border-bottom: 1px solid var(--hairline-strong);
white-space: nowrap;
}
Expand All @@ -246,7 +305,7 @@ button:focus-visible {
}

.results tbody td {
padding: var(--space-3) var(--space-4);
padding: var(--space-3);
border-bottom: 1px solid var(--hairline);
vertical-align: baseline;
}
Expand All @@ -266,8 +325,16 @@ button:focus-visible {
white-space: nowrap;
}

/* Prose-ish columns wrap so the table fits the width; the train notation and
figures above stay on one line. */
.results td.corr {
color: var(--ink-soft);
min-width: 11ch;
}

.results td.corr,
.results td.bench-cell {
white-space: normal;
}

.results .bench {
Expand Down Expand Up @@ -311,6 +378,33 @@ button:focus-visible {
font-size: var(--text-sm);
}

/* Visible "best" marker on the recommended row — not colour-only. */
.best-badge {
display: inline-block;
margin-right: var(--space-1);
font-size: var(--text-2xs);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
font-weight: 600;
color: var(--accent);
}

/* Plain-language reading guide under the table. */
.results-legend {
margin: var(--space-3) 0 0;
max-width: 62ch;
color: var(--ink-soft);
font-size: var(--text-xs);
line-height: var(--leading-normal);
}

.schematic-caption {
margin: var(--space-2) 0 0;
color: var(--ink-soft);
font-size: var(--text-xs);
text-align: center;
}

/* ---- Export controls --------------------------------------------------- */
.export-slot {
display: flex;
Expand Down Expand Up @@ -350,10 +444,13 @@ button[data-export]:hover {
gap: var(--space-4);
}

/* Hide interactive controls; keep the chosen train, schematic, table. */
/* Hide interactive controls and onboarding copy; keep the chosen train,
schematic, table, and the what-was-solved summary. */
.panel-slot,
.input-panel,
.export-slot {
.export-slot,
.masthead .intro,
.results-legend {
display: none !important;
}

Expand Down
23 changes: 23 additions & 0 deletions packages/web/src/ui/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,29 @@ describe("app", () => {
history.replaceState(null, "", "#");
});

it("teaches the cold visitor: intro, grouped controls, reading guide, share", () => {
const root = document.createElement("main");
mountApp(root);
// intro band frames the auto-solved example
expect(root.querySelector(".intro")!.textContent).toMatch(/live example/i);
// controls are grouped into two labelled fieldsets
const legends = [...root.querySelectorAll("fieldset.field-group > legend")].map((l) => l.textContent);
expect(legends).toEqual(["What to solve", "Constraints"]);
// the recommended row carries a visible "best" badge, not colour alone
const badge = root.querySelector('tr[data-best="true"] .best-badge')!;
expect(badge).not.toBeNull();
expect(badge.textContent).toBe("best");
// a plain-language reading guide sits under the table
expect(root.querySelector(".results-legend")!.textContent).toMatch(/each row is one gear train/i);
// the schematic is captioned once a result exists
expect(root.querySelector<HTMLElement>(".schematic-caption")!.hidden).toBe(false);
// a Copy link button shares the pre-solved permalink
const link = root.querySelector<HTMLButtonElement>('[data-export="link"]')!;
expect(link).not.toBeNull();
link.click(); // must not throw even where clipboard is unavailable
history.replaceState(null, "", "#");
});

it("shows a readable notice for malformed input instead of throwing", () => {
const root = document.createElement("main");
mountApp(root);
Expand Down
Loading
Loading