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
22 changes: 22 additions & 0 deletions docs/bugfix-map-editor-number-input-clearing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Bugfix: Map editor number inputs could not be cleared

## Symptom

In the map layout editor property panel, the line-width (and font-size)
`<input type="number">` fields snapped back as soon as the user deleted a
digit. Clearing the field to type a new value was impossible.

## Cause

`onChange` wrote `Number(event.target.value)` straight into object state.
An empty string becomes `0`, which React then pushed back into the
controlled `value`, so the field never stayed blank.

## Fix

`NumberField` keeps a string draft while the input is focused and only
commits through `parseLiveNumberDraft` when the draft is a finite number
inside `[min, max]`. On blur (or Enter) an invalid/empty draft falls back
to the last committed value. The same pattern was already used for print
scale and description cell size via `parseClampedNumberDraft`; the live
variant exists so partial edits like `"0."` do not commit prematurely.
15 changes: 11 additions & 4 deletions docs/course-maps.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,10 +250,17 @@ keep a single title row over code-sorted control rows.

Choose an object tool and click its intended page position to create it;
`Escape` cancels an armed tool. Rectangles and polygons share a **Fill**
property: none, solid colour, white-out, or ISOM 709 out-of-bounds purple
cross-hatch (0.2 mm lines / 1.2 mm gap at the base map scale, enlarged by
`overprintScale`). An optional border toggle controls stroke. Paths support
inserting and removing vertices. Alt-drag
property: none, solid colour, white-out, white-out outside (ink saver), or
ISOM 709 out-of-bounds purple cross-hatch (0.2 mm lines / 1.2 mm gap at the
base map scale, enlarged by `overprintScale`). An optional border toggle
controls stroke. Out-of-bounds borders default to the ISOM 709 bounding line
(0.4 mm, Feb 2024 revision) and always use the course purple — the colour
picker is locked so a later purple change recolours them. **White-out
outside** draws an even-odd ring against the map frame (everything inside
the frame except the shape is opaque white), so print ink is only spent on
the area of interest; click the white ring to select it, click inside the
shape hole to pass through to objects underneath. Paths support inserting
and removing vertices. Alt-drag
a selected vertex to create symmetric cubic Bezier handles. Text supports
sans-serif, serif, condensed and monospace fonts; clicking a variable inserts
it at the current text cursor. Objects can be fixed to paper or anchored to
Expand Down
3 changes: 2 additions & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ panels (Tools, Page, Graphics, Objects, Properties) keep the canvas usable on
mobile; the Objects card lists and deletes anything, including perfectly
stacked objects, and warns when items sit outside the printable area. The
editor supports click-to-place text, rectangles, polygons and paths with a
shared fill mode (none / solid / white-out / ISOM 709 out-of-bounds hatch),
shared fill mode (none / solid / white-out / white-out outside for ink
saving / ISOM 709 out-of-bounds hatch with purple-locked 0.4 mm border),
editable Bezier paths, and uploaded SVG/PNG graphics (free resize, Ctrl/Cmd
proportional, Shift crop) from a per-event or club library. Course overprint
follows ISOM: circles, numbers and lines enlarge with the map when the print
Expand Down
42 changes: 42 additions & 0 deletions e2e/course-maps.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,15 @@ test("creates templates, lays out several maps and exports PDFs", async ({
await expect(page.getByTestId("map-object-fill-mode")).toHaveValue(
"outOfBounds",
);
await expect(page.getByTestId("map-object-border")).toBeChecked();
await expect(page.getByTestId("map-object-stroke-follows-purple")).toBeVisible();
await expect(page.getByTestId("map-object-stroke-color")).toHaveCount(0);
await expect(page.getByTestId("map-object-stroke-width")).toHaveValue("0.4");
await page.getByTestId("map-object-stroke-width").fill("");
await expect(page.getByTestId("map-object-stroke-width")).toHaveValue("");
await page.getByTestId("map-object-stroke-width").fill("0.6");
await expect(page.getByTestId("map-object-stroke-width")).toHaveValue("0.6");

const rectangle = page.locator("[data-object-id]").last();
const beforeResize = await rectangle.boundingBox();
const resizeHandle = page.getByTestId("map-resize-se");
Expand Down Expand Up @@ -263,6 +272,39 @@ test("creates templates, lays out several maps and exports PDFs", async ({
const constrainedBox = await rectangle.boundingBox();
expect(constrainedBox!.x).toBeGreaterThanOrEqual(printableBox!.x - 1);

await page.getByTestId("map-object-fill-mode").selectOption("whiteoutInverted");
await expect(page.getByTestId("map-object-fill-mode")).toHaveValue(
"whiteoutInverted",
);
const invertedPath = page.locator(
'path[data-object-id][fill-rule="evenodd"]',
);
await expect(invertedPath).toHaveCount(1);
const invertedBox = await invertedPath.boundingBox();
expect(invertedBox).not.toBeNull();
// Switch selection via the object list (empty-canvas click is covered by
// the frame-sized white-out ring).
await page.getByTestId("map-panel-objects-toggle").click();
await page.getByTestId("map-object-list").locator("button").first().click();
await expect(page.getByTestId("map-object-text")).toBeVisible();
// White ring (frame corner opposite the constrained shape) re-selects.
await page.mouse.click(
invertedBox!.x + invertedBox!.width - 12,
invertedBox!.y + invertedBox!.height - 12,
);
await expect(page.getByTestId("map-object-fill-mode")).toHaveValue(
"whiteoutInverted",
);
// Hole falls through: click inside the shape does not select the inverted
// white-out (fill-mode control stays absent after selecting text first).
await page.getByTestId("map-object-list").locator("button").first().click();
await expect(page.getByTestId("map-object-text")).toBeVisible();
await page.mouse.click(
constrainedBox!.x + constrainedBox!.width / 2,
constrainedBox!.y + constrainedBox!.height / 2,
);
await expect(page.getByTestId("map-object-fill-mode")).toHaveCount(0);

await page.getByTestId("map-print-scale").fill("8000");
await expect(page.getByTestId("map-layout-save-status")).toHaveText("Saved", { timeout: 5_000 });
await page.getByTestId("map-editor-close").click();
Expand Down
61 changes: 61 additions & 0 deletions packages/shared/src/__tests__/course-maps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
defaultMapFrame,
expandMapText,
getPaperDimensions,
isWhiteoutObject,
mapToPage,
mapWindowForFrame,
pageToMap,
Expand Down Expand Up @@ -52,6 +53,26 @@ describe("course map schemas and geometry", () => {
).toThrow();
});

it("parses whiteoutInverted and treats it as a white-out", () => {
const inverted = courseMapObjectSchema.parse({
id: "inv",
kind: "path",
anchor: "page",
points: [
{ x: 10, y: 10 },
{ x: 50, y: 10 },
{ x: 30, y: 40 },
],
fillMode: "whiteoutInverted",
closed: true,
});
expect(inverted).toMatchObject({
kind: "path",
fillMode: "whiteoutInverted",
});
expect(isWhiteoutObject(inverted as CourseMapObject)).toBe(true);
});

it("normalizes legacy whiteout kinds into fillMode whiteout", () => {
expect(
courseMapObjectSchema.parse({
Expand Down Expand Up @@ -440,6 +461,30 @@ describe("course map SVG generators", () => {
height: 15,
fillMode: "outOfBounds",
},
{
id: "oob-border",
kind: "rectangle",
anchor: "page",
x: 70,
y: 40,
width: 20,
height: 15,
fillMode: "outOfBounds",
stroke: "#ff0000",
strokeWidthMm: 0.4,
},
{
id: "inverted",
kind: "rectangle",
anchor: "page",
x: 20,
y: 20,
width: 30,
height: 25,
fillMode: "whiteoutInverted",
stroke: "#000000",
strokeWidthMm: 0.35,
},
],
frame: document.mapFrame,
window: { minX: 0, minY: 0, width: 100, height: 100 },
Expand All @@ -455,6 +500,22 @@ describe("course map SVG generators", () => {
expect(svg).toContain('id="oob-oob"');
expect(svg).toContain('stroke-width="0.4"'); // 0.2 mm * overprintScale 2
expect(svg).not.toContain("mix-blend-mode");
// OOB border ignores stored stroke colour and uses purple.
expect(svg).toContain('data-object-id="oob-border"');
expect(svg).toMatch(
/data-object-id="oob-border"[^>]*stroke="#a626ff"/,
);
expect(svg).not.toMatch(
/data-object-id="oob-border"[^>]*stroke="#ff0000"/,
);
// OOB without border has stroke="none".
expect(svg).toMatch(/data-object-id="oob"[^>]*stroke="none"/);
// Inverted white-out is an even-odd ring against the map frame.
expect(svg).toContain('data-object-id="inverted"');
expect(svg).toContain('fill-rule="evenodd"');
expect(svg).toContain(
`M ${document.mapFrame.x} ${document.mapFrame.y} L ${document.mapFrame.x + document.mapFrame.width} ${document.mapFrame.y}`,
);
});
});

Expand Down
78 changes: 72 additions & 6 deletions packages/shared/src/course-maps/objects-svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export interface RenderMapObjectsOptions {
/** ISOM 2017-2 symbol 709 (2022 revision) at 1:15 000. */
export const OUT_OF_BOUNDS_LINE_MM = 0.2;
export const OUT_OF_BOUNDS_GAP_MM = 1.2;
/** ISOM 709 bounding line width (Feb 2024 revision). */
export const OUT_OF_BOUNDS_BORDER_MM = 0.4;

const FONT_STACKS = {
sans: "Liberation Sans, Arial, sans-serif",
Expand Down Expand Up @@ -142,6 +144,7 @@ function resolveFill(
case "solid":
return fill ?? "none";
case "whiteout":
case "whiteoutInverted":
return "#ffffff";
case "outOfBounds":
return patternId ? `url(#${patternId})` : "none";
Expand All @@ -161,6 +164,42 @@ function strokeAttrs(
return `stroke="${stroke}" stroke-width="${strokeWidthMm}"`;
}

/**
* Resolve stroke for a rect/path. Out-of-bounds borders always use the
* course purple (ISOM 709), ignoring any stored stroke colour.
*/
export function resolveObjectStroke(
fillMode: MapFillMode | undefined,
stroke: string | undefined,
strokeWidthMm: number | undefined,
purple: string,
): { stroke: string | undefined; strokeWidthMm: number | undefined } {
if (!stroke || strokeWidthMm === undefined) {
return { stroke: undefined, strokeWidthMm: undefined };
}
if (fillMode === "outOfBounds") {
return { stroke: purple, strokeWidthMm };
}
return { stroke, strokeWidthMm };
}

function frameRingPath(frame: MapRect): string {
const { x, y, width, height } = frame;
return `M ${x} ${y} L ${x + width} ${y} L ${x + width} ${y + height} L ${x} ${y + height} Z`;
}

function rectPathData(x: number, y: number, width: number, height: number): string {
return `M ${x} ${y} L ${x + width} ${y} L ${x + width} ${y + height} L ${x} ${y + height} Z`;
}

function isClosedFill(fillMode: MapFillMode): boolean {
return (
fillMode === "whiteout" ||
fillMode === "whiteoutInverted" ||
fillMode === "outOfBounds"
);
}

/**
* ISOM 709 purple cross-hatch pattern. Dimensions are at the base map
* scale and multiplied by `overprintScale` so they enlarge with the map.
Expand Down Expand Up @@ -239,11 +278,23 @@ function renderObject(
defs.push(outOfBoundsPatternDef(patternId, purple, overprintScale));
}
const fill = resolveFill(fillMode, object.fill, patternId);
const closed =
object.closed ||
fillMode === "whiteout" ||
fillMode === "outOfBounds";
return `<path ${common} d="${pathData(points, closed)}" ${strokeAttrs(object.stroke, object.strokeWidthMm)} fill="${fill}"/>`;
const closed = object.closed || isClosedFill(fillMode);
const shapePath = pathData(points, closed);
const resolved = resolveObjectStroke(
fillMode,
object.stroke,
object.strokeWidthMm,
purple,
);
if (fillMode === "whiteoutInverted") {
const ring = `${frameRingPath(options.frame)} ${shapePath}`;
const border =
resolved.stroke !== undefined
? `<path d="${shapePath}" fill="none" ${strokeAttrs(resolved.stroke, resolved.strokeWidthMm)}/>`
: "";
return `<g ${common}><path d="${ring}" fill="${fill}" fill-rule="evenodd" stroke="none"/>${border}</g>`;
}
return `<path ${common} d="${shapePath}" ${strokeAttrs(resolved.stroke, resolved.strokeWidthMm)} fill="${fill}"/>`;
}
case "rectangle": {
const p =
Expand All @@ -268,7 +319,22 @@ function renderObject(
defs.push(outOfBoundsPatternDef(patternId, purple, overprintScale));
}
const fill = resolveFill(fillMode, object.fill, patternId);
return `<rect ${common} x="${p.x}" y="${p.y}" width="${size.width}" height="${size.height}" fill="${fill}" ${strokeAttrs(object.stroke, object.strokeWidthMm)}/>`;
const resolved = resolveObjectStroke(
fillMode,
object.stroke,
object.strokeWidthMm,
purple,
);
if (fillMode === "whiteoutInverted") {
const shapePath = rectPathData(p.x, p.y, size.width, size.height);
const ring = `${frameRingPath(options.frame)} ${shapePath}`;
const border =
resolved.stroke !== undefined
? `<path d="${shapePath}" fill="none" ${strokeAttrs(resolved.stroke, resolved.strokeWidthMm)}/>`
: "";
return `<g ${common}><path d="${ring}" fill="${fill}" fill-rule="evenodd" stroke="none"/>${border}</g>`;
}
return `<rect ${common} x="${p.x}" y="${p.y}" width="${size.width}" height="${size.height}" fill="${fill}" ${strokeAttrs(resolved.stroke, resolved.strokeWidthMm)}/>`;
}
case "image": {
const p =
Expand Down
13 changes: 12 additions & 1 deletion packages/shared/src/course-maps/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,15 @@ export const mapLineObjectSchema = z.object({
* - `none` — stroke only (or invisible if no stroke)
* - `solid` — solid colour from `fill`
* - `whiteout` — opaque white, drawn under the course overlay
* - `whiteoutInverted` — opaque white outside the shape (even-odd ring
* against the map frame), for ink-saving print areas
* - `outOfBounds` — ISOM 709 purple cross-hatch
*/
export const mapFillModeSchema = z.enum([
"none",
"solid",
"whiteout",
"whiteoutInverted",
"outOfBounds",
]);
export type MapFillMode = z.infer<typeof mapFillModeSchema>;
Expand Down Expand Up @@ -170,7 +173,15 @@ export type CourseMapObject = z.infer<typeof courseMapObjectUnion>;
export function isWhiteoutObject(object: CourseMapObject): boolean {
return (
(object.kind === "rectangle" || object.kind === "path") &&
object.fillMode === "whiteout"
(object.fillMode === "whiteout" || object.fillMode === "whiteoutInverted")
);
}

/** True when the white-out fills everything outside the shape (ink saver). */
export function isInvertedWhiteoutObject(object: CourseMapObject): boolean {
return (
(object.kind === "rectangle" || object.kind === "path") &&
object.fillMode === "whiteoutInverted"
);
}

Expand Down
Loading
Loading