diff --git a/docs/bugfix-overprint-cut-meridian-tilt.md b/docs/bugfix-overprint-cut-meridian-tilt.md new file mode 100644 index 0000000..6a90482 --- /dev/null +++ b/docs/bugfix-overprint-cut-meridian-tilt.md @@ -0,0 +1,43 @@ +# Bugfix: overprint cut slits skewed by meridian tilt + +## Symptom + +Automatic circle slits on the course editor / map viewer were rotated a +few degrees clockwise of the black feature they were meant to clear. +On `Ny_karta_test` the bias was ~3.3° — enough that a boulder on the +north rim of a control looked like it sat in the middle of purple ink. + +Print/PDF maps were fine: map and overlay share one +`windowRotationDeg`, so cut angles and map features stay co-rotated. + +## Cause + +Stored cut angles are paper-relative (compass degrees in the OCAD +millimetre plane). The viewer must rotate them by the same amount it +rotates the base map so slits land on features after true-north +alignment. + +`MapViewer` added `northOffset` to every cut angle. That value is +`displayNorthOffsetDeg` = paper-to-true-north bearing **plus** the +median meridian-line tilt (`meridian.medianTiltDeg`). The tilt is needed +so drawn 601.x north lines stand vertical on screen, but cuts only need +the paper→true-north part. Folding the tilt in rotated every slit by +that extra angle. + +## Fix + +- Pure helper `cutRotationDeg(northOffset, tiltDeg)` in + `packages/api/src/map-north.ts`: + `northOffset - (tiltDeg ?? 0)`, null when `northOffset` is null. +- `course.mapMetadata` returns `cutRotationDeg`. +- `MapViewer` uses `cutRotationDeg` for the slit `adj` computation; + map/tile rotation still uses `northOffset`. + +## Tests + +- Unit: `map-north.test.ts` (`cutRotationDeg`). +- Integration: `mapMetadata.cutRotationDeg` asserted alongside profile + changes in `map-tiles.test.ts`. +- E2E: existing cut/gap assertions in `course-editor.spec.ts` still + pass; they plant a boulder due north of control 79 so any residual + tilt bias would miss the slit. diff --git a/docs/bugfix-small-object-overprint-cuts-and-label-halo.md b/docs/bugfix-small-object-overprint-cuts-and-label-halo.md new file mode 100644 index 0000000..9e9ed7d --- /dev/null +++ b/docs/bugfix-small-object-overprint-cuts-and-label-halo.md @@ -0,0 +1,46 @@ +# Bugfix: small-object cuts and control-number contrast + +## Problem + +The first automatic overprint-cut implementation opened purple over +every configured black line and area object. After IOF colour stacking +landed, those cuts were unnecessary: cliffs, paths, walls and buildings +already render above lower purple. Cutting them fragmented circles and +legs without improving readability. + +Knolls had the opposite inconsistency. They slit control circles but +were explicitly skipped by leg gaps, so a leg could still hide the same +small feature. + +Control numbers also had no knockout. Purple text over dense map ink +could be hard to read, especially at print scale. + +## Fix + +- Automatic circle slits and leg gaps now apply only to OCAD point + objects with ISOM numbers 109, 110, 203, 204, 205 or 207. +- Knolls and compact rocks use the same rule for circles and legs. +- Long line and area objects never create automatic cuts. +- Interactive and print/PDF control numbers receive an always-on white + halo with stroke width 12 % of the number font size. SVG + `paint-order="stroke fill"` keeps the purple glyph crisp. + +## Existing events + +Cuts are stored in editor course GeoJSON, so changing only the algorithm +would leave old building/path cuts visible indefinitely. +`events.overprint_cuts_version` solves that: + +- migration marks existing events as v1; +- new events default to v2; +- the first geometry read for a v1 event rebuilds every + `geometrySource: "editor"` course and advances the event to v2; +- imported OCD/XML geometry is never rewritten. + +## Coverage + +- Unit tests reject line/area cuts and verify knoll gaps. +- Integration tests verify building legs stay whole, boulder legs gap, + and v1 stored geometry migrates lazily. +- Shared print-overlay tests assert the white halo. +- Course-editor E2E asserts the interactive label halo. diff --git a/docs/course-editor.md b/docs/course-editor.md index 8997b3c..a8cac51 100644 --- a/docs/course-editor.md +++ b/docs/course-editor.md @@ -208,45 +208,55 @@ missing suggestion is cheaper than a wrong one. ### Automatic overprint cuts -The purple overprint must not hide important map detail, so course -setters cut it: **circle slits** where a control circle crosses black -features (rock and man-made symbols) or knolls, and **leg gaps** where a -leg line passes over black features. Oxygen computes both automatically -— there is no manual cut UX (yet); the cuts simply follow the map. +The purple overprint must not hide compact map detail, so course setters +cut it around **small point objects only**: control-circle slits and leg +gaps reveal knolls, elongated knolls, rocky pits, boulders, large +boulders and boulder clusters. Long objects (cliffs, paths, walls, +buildings and areas) remain uncut; the IOF colour stack keeps their ink +readable without fragmenting the purple. Oxygen computes the cuts +automatically — there is no manual cut UX (yet). + An event-level **Auto cuts** toolbar toggle + (`course.getOverprintCuts` / `course.setOverprintCuts`, + `events.auto_overprint_cuts`, default on) skips + `decorateOverprintCuts` for editor geometry when off; imported + OCD/XML geometry keeps its file-authored cuts either way. - **When**: at every geometry rebuild (`rebuildCourseGeometry` — course create/update, control move/delete) and after a map upload, which rebuilds all `geometrySource: "editor"` courses so cuts follow the new map. Imported (`ocd`/`xml`) geometry keeps its file-authored cuts and - is never touched. + is never touched. Toggling auto cuts rebuilds every editor course. - **Where**: `packages/api/src/overprint-cuts.ts`, pure and unit-tested. It reuses the slimmed base-map object cache built for the description autodetect (`event-map-objects.ts`) and stores the result *in* the course GeoJSON: `properties.cuts` (`{start, end}` compass degrees, the same convention the OCD importer writes) on control point features, `properties.gaps` (`{from, to}` fractions of the leg) on leg features. - No schema change — it all rides in the existing `geometry` JSONB. -- **Which symbols**: a static ISOM-number table (`CUT_KINDS`) — rock - (201–207, 215), black man-made (502–518, 521–532), and knolls - (109/110, slits only — a leg over a knoll is normal). Pattern-fill - areas (boulder fields, stony ground) are excluded: their dots are - symbol fill, not objects, so a cut at the invisible area boundary - would look random. Blue/green/yellow symbols never cut. + The cuts ride in the existing `geometry` JSONB. +- **Which symbols**: `CUT_SYMBOLS` contains ISOM 109/110 and + 203/204/205/207. The object must also be an OCAD point object. Knolls + are treated exactly like rocks for both circle slits and leg gaps. + Cliffs (201/202), gigantic boulder areas (206), trenches, black + man-made objects, pattern fills, water and vegetation never cut. - **Geometry rules**: cuts are deliberately *tight* — the overprint stroke is 0.35 mm wide and a compact ISOM point symbol ~0.5 mm across, so clearing much more than the feature itself just fragments the circle without revealing more map. Point features cut the rim (or the leg, centered on the projection) when they sit within **0.45 mm** of - it, opening **±0.4 mm** of ink — about 18° of rim, 0.8 mm of leg. Line - features cut **±0.35 mm** at each rim/leg crossing, wider for oblique - leg crossings (`half-width / sin θ`, capped at 1.2 mm). Solid black - areas (buildings, canopies, ruins, gigantic boulders) cut the whole - stretch of rim/leg *inside* them (rim sampled every 4°, leg via - entry/exit intersection parameters). Overlapping cuts merge; slivers - (< 4° / < 0.6 mm) drop; sanity caps keep a circle whole when > 300° - would vanish and never erase more than 70 % of a leg. Legs also keep + it, opening **±0.4 mm** of ink — about 18° of rim, 0.8 mm of leg. + Overlapping cuts merge and slivers (< 4° / < 0.6 mm) drop. Legs keep 3 mm (1.2 × circle radius) at each end — the viewer clips that zone around circles anyway. +- **Stored-geometry migration**: `events.overprint_cuts_version` marks + which algorithm decorated every editor course. Existing v1 events are + rebuilt lazily on their first geometry read and advanced to v2; + imported geometry is untouched. +- **Screen rotation**: stored cut angles are paper-relative. The viewer + adds `cutRotationDeg` (= `northOffset − meridianTiltDeg`) when drawing + slits so they stay aligned with map features after the map is rotated + to true north. Using full `northOffset` (which includes meridian tilt) + skewed every slit by the tilt (see + [bugfix-overprint-cut-meridian-tilt.md](bugfix-overprint-cut-meridian-tilt.md)). - **Rendering**: circle `cuts` were already consumed by the viewer's `drawBrokenCircle` (OCD-imported slits used the same path). Leg `gaps` are new: `subtractLegGaps` in `MapViewer.tsx` splits the screen-space @@ -254,6 +264,14 @@ leg line passes over black features. Oxygen computes both automatically a leg is locally linear) before the usual circle clipping; gapped segments carry `data-leg-gapped="true"` for tests. +### Control-number contrast + +Control numbers always render with a narrow opaque white halo +(`paint-order="stroke fill"`, stroke width 12 % of the font size). The +purple glyph remains unchanged, while the small knockout separates it +from dense map ink. The same default is used by the interactive SVG and +the print/PDF overlay. + Placement and dragging work in **map millimetres** (the `xpos`/`ypos` paper coordinate space) — the viewer converts screen pixels via an affine transform. When `course.mapMetadata` carries **calibration @@ -420,12 +438,11 @@ their hit targets so "click → Add to course" still works on them. distance, area containment, per-column-D-code dedupe, all eight bearings, unmapped symbols ignored. `packages/api/src/__tests__/overprint-cuts.test.ts` — automatic cuts - over synthetic objects: rim slits for boulders/knolls (not for a - feature under the circle centre), both crossings of a line, buried - rim stretches inside a building, wrap-around slits, non-black symbols - ignored, leg gaps for points / oblique line crossings / building - interiors, end-zone preservation, merging and both sanity caps, and - the geometry decorator (start/finish untouched, stale cuts removed). + over synthetic objects: rim slits and leg gaps for compact boulders / + knolls (not for a feature under the circle centre), long line and area + objects ignored, wrap-around slits, end-zone preservation, merging, + and the geometry decorator (start/finish untouched, stale cuts + removed). - **E2E**: `e2e/course-editor.spec.ts` — imports `test.ocd` for coordinates + map, then: place a control via the contextual **Add control** action, read the suggested code from the toolbar, drag it to @@ -466,9 +483,10 @@ their hit targets so "click → Add to course" still works on them. boulder and building (see [e2e-test-ocd-fixture.md](e2e-test-ocd-fixture.md)); `integration/overprint-cuts.test.ts` proves the cuts land in the - stored geometry (rim slit over the fixture boulder, leg gap through - the building), recompute when a control moves away, and that a map - upload rebuilds editor-course geometry with fresh cuts. + stored geometry (rim slit and leg gap over fixture boulders, no gap + through the building), recompute when a control moves away, lazily + migrate v1 geometry, and that a map upload rebuilds editor-course + geometry with fresh cuts. Printing goes through [iof-coursedata-export.md](iof-coursedata-export.md): the Courses page diff --git a/docs/course-maps.md b/docs/course-maps.md index 30c3814..0844aee 100644 --- a/docs/course-maps.md +++ b/docs/course-maps.md @@ -102,17 +102,20 @@ Structured JSON is validated at every tRPC boundary with the Zod schemas in ```text map_files.file_data -> readOcad + -> applyIofColorStack (rewrite colour renderOrder) -> discard objects outside print window + 20 mm - -> ocadToSvg - -> nested SVG clipped to map frame - -> white-outs - -> course overlay + -> ocadToSvg (full) + ocadToSvg (ink, toColor) + -> nested SVGs clipped to map frame: + full map → lower purple → ink → whiteouts → upper purple -> IOF control descriptions -> text / lines / rectangles -> rsvg-convert --format=pdf -> pdf-lib page merge ``` +See [`map-color-stack.md`](map-color-stack.md) for IOF lower/upper purple +stacking (no blend modes). + Course symbols start from norm dimensions in output millimetres: 2.5 mm control radius and 0.35 mm stroke by default. When a base map is enlarged for printing, the overprint is enlarged by the same @@ -262,8 +265,9 @@ objects rather than UUIDs. The layout editor and PDF composer use the same course-overlay geometry as the regular map view: legs are clipped around controls, imported overprint gaps and control-circle slits are retained, and control numbers avoid symbols and -course lines. Print output uses multiply blending so map detail remains -visible beneath the purple ink. +course lines. Print (and the live map) use IOF colour stacking — lower purple +under the map ink layer, upper purple on top — instead of blend modes; see +[`map-color-stack.md`](map-color-stack.md). Appearance dimensions (circle radius, line width, number height) are ISOM dimensions **at the base map scale** — the defaults match ISOM 2017-2: diff --git a/docs/e2e-test-ocd-fixture.md b/docs/e2e-test-ocd-fixture.md index 316878a..6fde162 100644 --- a/docs/e2e-test-ocd-fixture.md +++ b/docs/e2e-test-ocd-fixture.md @@ -38,8 +38,8 @@ none of that. (the overprint-cut targets above). - Integration tests: `course-import-class-fallback`, `course-import-coords`, `map-tiles`, `course-export`, `description-autodetect` (asserts the - boulder/building coordinates above), `overprint-cuts` (rim slit over - the 68/42 boulder, leg gap through the building). + boulder/building coordinates above), `overprint-cuts` (rim slit and + leg gap over the 68/42 boulder; no gap through the building). ## Regenerating diff --git a/docs/features.md b/docs/features.md index cb23573..44b8366 100644 --- a/docs/features.md +++ b/docs/features.md @@ -62,7 +62,7 @@ The Course Editor (overflow menu) turns the map into an editing surface with con Descriptions get a head start from the map itself: place a control and the same menu proposes what it sits on — boulder, path, marsh, building — read straight out of the uploaded OCAD map's objects, with a side-of direction ("N side") when the control is beside the feature rather than on it. One click fills the description, `Ctrl+Z` takes it back — and moving a control re-offers suggestions for the new spot, since the old description no longer applies. The course panel floats over the map itself, so course building keeps working in fullscreen, and with no course selected the description sheet lists every control under an "All controls" title instead of going blank. See [course-editor.md](course-editor.md) for the architecture and [control-descriptions-and-editor-geometry.md](control-descriptions-and-editor-geometry.md) for the backend contract. -The overprint also cuts itself automatically, the way a careful course setter would by hand: control circles get slits where they would hide black map features (boulders, cliffs, walls, paths, buildings) or knolls, and leg lines get gaps where they pass over black features — recomputed on every edit and after a map upload, with no manual cut tool needed. The cuts are kept tight, clearing the feature and no more, so circles stay readable instead of fragmenting. Imported OCAD courses keep the slits authored in the file. +The overprint also cuts itself automatically, the way a careful course setter would by hand: control circles get slits and leg lines get gaps only for compact knolls and rock objects (rocky pits, boulders, large boulders and clusters). Long cliffs, paths, walls, buildings and areas stay uncut because the colour stack already keeps their ink readable. An **Auto cuts** toolbar toggle turns the automatic decoration off for the whole event when you want unbroken purple. Cuts are kept tight, clearing the small feature and no more, and existing editor courses migrate to the current cut algorithm on first view. Imported OCAD courses keep the slits authored in the file. Control numbers always have a narrow white halo for contrast over dense map ink in both the viewer and printed/PDF maps. Map ink that should sit above purple follows IOF colour-stack **profiles** (ISOM / ISSprOM / ISSkiOM / ISMTBOM, or auto from the file's Lower purple colour / map scale), with optional north lines kept under the course purple — see `docs/map-color-stack.md`. Events that share a club map share the tile cache via a content `renderKey`. ### Course maps and export for printing diff --git a/docs/map-color-stack.md b/docs/map-color-stack.md new file mode 100644 index 0000000..26be236 --- /dev/null +++ b/docs/map-color-stack.md @@ -0,0 +1,105 @@ +# Map colour stack (IOF lower/upper purple) + +Oxygen prints and displays course maps with **colour-table stacking**, not +blend modes. That matches ISOM 2017 Appendix 1 / ISSprOM digital print +practice: lower purple (circles, legs, start, finish) sits under black, +brown and 100 % blue line/point colours so those features stay sharp; +upper purple (control numbers, marked/forbidden routes, out-of-bounds +hatch) sits on top. + +## Why a raster ink layer + +Cutting purple where map ink should win requires knowing where the ink +is. Vector outlining of every OCAD symbol is not realistic. Colour- +thresholding a composite fails (lake blue equals stream blue). So the +renderer ships a transparent **ink layer** — the colours classified above +lower purple — and draws it on top of the purple. + +Drawing that ink a second time over the full composite is idempotent, so +legacy consumers that only show the composite still look correct. + +## Profiles (`map-color-stack.ts`) + +IOF *Printing and Colour Definitions* rev. 4, chapter 7 defines what sits +above lower purple per map specification. Oxygen exposes four profiles +plus **auto**: + +| Profile | Above lower purple | +|---|---| +| `isom` | black 100 % (line/point/text), blue 100 % (line/point), brown 100 % (line/point), white (line/point), green 100 % (point only) | +| `issprom` | black 100 % (line/point/text), white (line/point), green 100 % (point only), purple 50 % area (18/43/0/0) — **no** brown/blue | +| `isskiom` | black 100 %, blue 100 % (line/point), green 100 % (line/point, Ski-O track green), white | +| `ismtbom` | black 100 %, blue 100 % (line/point), green 100 % (point), white, purple 50 % area | + +`applyIofColorStack(file, { profile, overrides?, scale? })` returns +`{ boundary, above, below, warnings, resolvedProfile, resolvedBy }`. + +### Auto detection order + +1. A colour whose name matches `/lower\s*purple|undre\s*lila/i` → boundary + is that colour's position in the file's own order (`resolvedBy: + "file-colour"`). +2. Else `scale <= 5000` → `issprom`; otherwise `isom` (`resolvedBy: + "scale"` or `"default"` when scale is missing). + +Explicit profile selection sets `resolvedBy: "explicit"`. + +### Overrides + +`color_overrides` JSONB (`{ above?: number[]; below?: number[] }` — +OCAD colour numbers) is applied after classification on every path. +Data model + API only in this release; no UI yet. + +### North lines under the course + +`north_lines_below` (default `true`) is an **object** filter, not a +colour move: the blue 100 % line colour is shared with streams. Ink +renders pass +`objects: file.objects.filter(o => Math.floor(o.sym / 1000) !== 601)` +into `ocadToSvg` / `objectsInWindow`. The composite keeps all objects. + +## UI / API + +- Event map: `course.setMapColorStack` + MapPanel select + (`data-testid="map-color-profile"`) and north-lines checkbox. +- Club library: `clubMap.setColorStack` + Settings → Maps card controls. +- `course.mapMetadata` exposes `colorProfile`, `northLinesBelow`, + `resolvedProfile`, `resolvedBy`, and `renderKey`. + +## Tiles (256×512 stacked PNG) + +Each `map_tiles` row stores one PNG keyed by **render key** (content + +stack settings — see [map-tile-rendering.md](map-tile-rendering.md)): + +``` ++----------------+ +| composite 256² | ← opaque full map (top half) ++----------------+ +| ink 256² | ← transparent above-purple ink (bottom half) ++----------------+ +``` + +Same row count and request count as before; ink compresses well (~+25 % +bytes). The viewer slices with an overflow clip (`TileLayer` `half` +prop). Two layers share one blob cache via `TileBlobCacheProvider`. +Client URLs carry `?v=` so a profile change busts the cache; +`f=2` remains for legacy-format safety. + +## Print / layout editor + +Order in the page SVG: + +1. full map +2. lower purple (`course-overlay-lower`) +3. ink nested SVG (`map-ink`, transparent root fill) +4. whiteouts +5. upper purple (`course-overlay-upper`) + OOB hatch (no multiply) +6. descriptions, foreground layout objects + +`window.png?layer=full|ink` feeds the layout editor the same sandwich. + +## Out of scope + +- Overrides UI (API + schema only) +- Print profiles (per-printer CMYK) and CMYK PDF output +- `rendered_maps` (still per-event) diff --git a/docs/map-tile-rendering.md b/docs/map-tile-rendering.md index 4dd1525..4701047 100644 --- a/docs/map-tile-rendering.md +++ b/docs/map-tile-rendering.md @@ -11,23 +11,27 @@ Code: `packages/api/src/map-tiles.ts` (pipeline and routes), ``` map_files.file_data (OCD blob) - │ readOcad + ocadToSvg (ocad2geojson, cached per event) + │ readOcad + applyIofColorStack + ocadToSvg×2 ▼ -SVG document ──── viewBox rewrite ────► window raster (resvg) +SVG full + SVG ink ─ viewBox rewrite ─► window rasters (resvg) │ │ │ │ bilinear warp per tile │ ▼ - └─ getCrs()/getBounds() ──► tile geometry ──► 256×256 PNG ──► map_tiles + └─ getCrs()/getBounds() ──► tile geometry ──► 256×512 stacked PNG ──► map_tiles + (composite top, ink bottom) ``` A tile request that misses `map_tiles` renders the whole *block* of tiles around it, writes them all back, and serves the one asked for. +The ink half is transparent line art (colours above lower purple); the +viewer slices the PNG so course purple can sit between the halves. See +[`map-color-stack.md`](map-color-stack.md). ## Routes | Route | Purpose | |---|---| -| `GET /api/map-tile/:nameId/:z/:x/:y` | One 256×256 PNG. Empty / out-of-map tiles return a **200 transparent PNG** (not 204; see `docs/bugfix-red-empty-tiles.md` for the time the "transparent" pixel was red). 404 for an unknown event. 500 on render failure. | +| `GET /api/map-tile/:nameId/:z/:x/:y` | One **256×512** stacked PNG (composite top, ink bottom). Empty / out-of-map tiles return a **200 transparent PNG** (not 204; see `docs/bugfix-red-empty-tiles.md`). 404 for an unknown event. 500 on render failure. Client URLs include `f=2` so caches cannot serve pre-stack 256×256 tiles. | | `GET /api/map-tile-progress` | Pre-cache progress for the event in the `x-competition-id` header. | Both are guarded by `assertRestAccess` with the `courses.view` capability @@ -126,8 +130,38 @@ it costs time rather than correctness. Nothing in the tile path requires a single instance. Uploading a map fires `onMapUpload`, which drops the SVG cache entry and -any in-flight blocks for that event; `applyEventMap` deletes the event's -`map_tiles` rows in the same transaction. +any in-flight blocks for that **render key**. `applyEventMap`, rotation +changes, and colour-stack updates recompute `map_files.render_key` and +call `gcOrphanTiles()` — they no longer `DELETE FROM map_tiles WHERE +event_id = …`. + +## Content-keyed tile cache + +`map_tiles` is keyed by `render_key`, not `event_id`: + +``` +renderKey = sha256( + file_hash | rotationCorrection | profile | JSON(overrides) + | northLinesBelow | TILE_FORMAT +).slice(0, 32) +``` + +Pure helper: `computeRenderKey()` in `packages/api/src/map-render-key.ts`. +`file_hash` is `sha256(file_data)` (backfilled on migrate; lazily filled +on upload). `render_key` is nullable and filled on first +`ensureEventMapRenderKey` / tile / metadata read. + +Two events that adopt the same club-library map (same blob + same stack +settings) share tile rows. Changing profile or north-lines on one event +mints a new key; `gcOrphanTiles` drops rows whose key is no longer +referenced by any `map_files` or `club_map_files` row. A club-library +map keeps its tiles even when every event drops it — re-add is instant. + +Client `tileVersion` is the `renderKey` string (`?v=`). `TILE_FORMAT` +is already inside the key; `f=` stays for week-old browser caches. + +Backup / showcase dumps omit `map_tiles` (pure cache; regenerates on +first view). ## Pre-caching and progress diff --git a/docs/screenshots/fixtures/showcase.sql b/docs/screenshots/fixtures/showcase.sql index 0532c20..fcb95c4 100644 --- a/docs/screenshots/fixtures/showcase.sql +++ b/docs/screenshots/fixtures/showcase.sql @@ -3535,12 +3535,6 @@ INSERT INTO runners ("id", "event_id", "seq", "class_id", "club_name", "eventor_ -- ─── map_files ───────────────────────────────────── INSERT INTO map_files ("event_id", "file_name", "file_data", "uploaded_at") VALUES (9876543, E'demo-map.ocd', '\xad0c0000e2070000ff100000131f00000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000044100000200000000900000000000000641000001f000000090000000000000083100000200000000900000000000000a31000001e0000000900000000000000c11000003e0000000f040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000507572706c65096e30096333352e30096d38352e300979302e30096b302e3000426c61636b096e310963302e30096d302e300979302e30096b3130302e300059656c6c6f77096e320963302e30096d32372e30097937392e30096b302e3000426c7565096e3309633130302e30096d302e300979302e30096b302e3000096d3135303030096735302e303030303030097231096435303009693130303009783637393030300979363537323030300961302e30303030303030300000000000031500006b180000ab1b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000068030000a8b40700020000000000000014000000000000000000010001000000000000000000000000000000000000000000000000000000500061007400680000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001001900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400300003826060003000000000000000000000000000000000001000200000000000000000000000000000000000000000000000000000052006f0075006700680020006f00700065006e0020006c0061006e006400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000001000000000000000000000000000000000068030000a82b090002000000000000000a0000000000000000000100030000000000000000000000000000000000000000000000000000004d00610067006e00650074006900630020006e006f0072007400680020006c0069006e00650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000beeaff00cfffff00de2000002227001747000060000000382606000300010000000000000000000018edff00cfffff0068edff002227007747000048000000a82b090002000100000000000000000000c8f1ff00cfffff0018f2ff00222700bf47000048000000a82b09000200010000000000000000000078f6ff00cfffff00c8f6ff002227000748000048000000a82b09000200010000000000000000000028fbff00cfffff0078fbff002227004f48000048000000a82b090002000100000000000000000000d8ffff00cfffff00280000002227009748000048000000a82b09000200010000000000000000000088040000cfffff00d8040000222700df48000048000000a82b09000200010000000000000000000038090000cfffff00880900002227002749000048000000a82b090002000100000000000000000000e80d0000cfffff00380e00002227006f49000048000000a82b09000200010000000000000000000098120000cfffff00e8120000222700b749000048000000a82b09000200010000000000000000000048170000cfffff0098170000222700ff49000048000000a82b090002000100000000000000000000f81b0000cfffff00481c0000222700474a000048000000a82b090002000100000000000000000000beeaff00a2010000461c0000dd04008f4a000078000000a8b4070002000100000000000000000000beeaff00070a0000461c0000b20c00074b000078000000a8b4070002000100000000000000000000beeaff0076120000461c00000716007f4b000078000000a8b4070002000100000000000000000000beeaff00091b0000461c0000611e00f74b000078000000a8b40700020001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000382606000301000000000000000000000000000000000000000000000000000000000000000000000000000005000000000000000000000000e6eaff00f7ffff00b6200000f7ffff00b6200000fa260000e6eaff00fa260000e6eaff00f7ffffa82b0900020100000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000040edff00f7ffff0040edff00fa2600a82b09000201000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000f0f1ff00f7ffff00f0f1ff00fa2600a82b09000201000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000a0f6ff00f7ffff00a0f6ff00fa2600a82b0900020100000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000050fbff00f7ffff0050fbff00fa2600a82b0900020100000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000f7ffff0000000000fa2600a82b09000201000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000b0040000f7ffff00b0040000fa2600a82b0900020100000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000060090000f7ffff0060090000fa2600a82b09000201000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000100e0000f7ffff00100e0000fa2600a82b09000201000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c0120000f7ffff00c0120000fa2600a82b0900020100000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000070170000f7ffff0070170000fa2600a82b09000201000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000201c0000f7ffff00201c0000fa2600a8b407000201000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000e6eaff0039030000eef1ff00ca010000f6f8ff0038040000feffff00b5040000060700008c0300000e0e00007004000016150000360300001e1c0000800400a8b407000201000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000e6eaff002f0a0000eef1ff00e00a0000f6f8ff00ca0b0000feffff00d30b000006070000850b00000e0e00008a0c000016150000680c00001e1c0000570c00a8b407000201000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000e6eaff0021130000eef1ff00aa150000f6f8ff0047150000feffff007414000006070000df1500000e0e00008d14000016150000811300001e1c00009e1200a8b407000201000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000e6eaff00c71d0000eef1ff00391e0000f6f8ff00941d0000feffff00ec1c000006070000381e00000e0e0000ae1c0000161500007f1d00001e1c0000311b00'::bytea, '2026-05-21T21:27:08.517Z'); --- ─── map_tiles ───────────────────────────────────── -INSERT INTO map_tiles ("event_id", "z", "x", "y", "tile_data") VALUES (9876543, 11, 1127, 603, '\x89504e470d0a1a0a0000000d49484452000001000000010008060000005c72a8660000000970485973000003e8000003e801b57b526b0000092249444154789ceddddbaf5c551900f03ef8e8df804aa331d1b608bd08180d1a52a2062f90da9068a2a909b1264a5b0cc60788844be041459110a3d5448e24bc089a68d1835c9a0a06ab0535a2c75e68b51714694b69697b3a66edceb7baf69a3da7fbf42005fdfd929dd93367f69e69f7f9be75ddebcc9b0700000000000000f00a1a24932b9aedc003d70fdeb970c9a0e43f1bfe87a5a04f5b24819400d2d67a7d3018dcf7a377375b97b3fd6f00ced0606a22077b53fa0f93c0e0c55df9f5d86e7deadbb9a61009a30f17075ec30960e70d4b9b40fde3fa7539c0bb6a0569db7ecd393959343582a989e6b552fdbcf5b3356f9220e0b5a00eda267087015f2680559f5d7df2f9d4446e0a4473a04c0c23c17ecd3983a3d3c7c62783e298b4ffbe07af3af939dbef578380ffb61c65d3d37937057659e297edfe78de7abd673320bcb8f98191d752e0778ae6483437d2364c4225bf297006ba622e4af728d5d3feb6356f1e3cb6fb8956e91fdb45cb9675760ea663dfb5697ff3f8f2ae3f0ce66d38905f2fdf13cf67aa49dcbee55bb946d27aece017017a8a20dcfdcd2b72404630c77e9910a2e44f5b2ab5cb1a41573fc0b866415722e8dacf8a0ec748008b2f3022017352c7591dcc29d8ea0450067ddd44982988c725845a3d0f21fa25d216e768de331cbd88f71fddfde7e6d1af04f454075c19fc69c82fed2f3cef54a95f37032209cc14c011c43bae7bfb60c7b56f9db1c370dcf15dd277a99b03e958171f7a4a01930229d971edfc56ef7eec47a99b7f36ec98abfb02220073004f4d0c76ac7d4bf35807796bbff8799900caf7a6f34c9f981e793d77104edd938f73f1a1a7728cbfab97bf0cecae0ec0fa7de57eeaf44bfbef597cc1ecabff5552e8736c1ce3e2430f295852d579c1a2a5b9a4ffc9fac5ad982a033b05580aeaaebe8032293cfb950523c9a076ba4ebf4804278e1c6a3ddfb8f1d7ad4ec4a669b16e7eeb58171f7ac8813db962a4f32fed1ffffd5d9d257dee841b1e17e7284710b67eb168320c4be7077f3139b6f02e6b208de1f922d0bb9a05650289d75d78e8a90cb8e8edaf832cbdf6f9d54b5b37084d1f7a3e076924845629fdc8dafc3c92c74c521f44cc11787cefe67cee96d34c363abae72f1200cc4604691eca2beefa1b9c38915f6bcdfbaffa035a257331532fce335333a02cc1cbfdb41dd9fedb5e09e0c0a67b5ac7fb0d809e52c05c78d125834f7e64f94802a80337edefbcfe54491ed5f2633ffd684e084d293eb9a229d1bbe60b9c6e98afacca3f7fff8da79f1bd0c1c5879ea2c4ae87f7caa09f69bc3e0233bf2f95fc932b9a1982b145a228fb11c64d1eeab2e4c9970683c99523df232599ba3370cddaeb2400e86b641cbf4a00bd86de8a1a430af8376cd89b6f0f4e1389cafb09eae1c33289d49f198f2901344960d8ac1899035071f5a1a79180ac16f8e89ab1b7f243978e045d04f81d6b2f6d823eb6f0f0ce4d83277ef364677f40ddfe3ff6dcf6d667e704504f322abe63f93d5d7ce8a18996f216db622b83379b9a18ec7fe4bb63c7ef5bc38343cd79a6269aea7a3985b83c6ee179cbf2f9eb7347f0470238fec29e91e00f318ae0e2430f3980aae0afa7e7feeae147bb1342a16b16609c27b5cbd37e0ad0b4453037e72f66fb757d469d008eeefbdb8cdf4302809ec6d600aa12f5749d80f53cff2baf3cb9b4d8b8053f52301f7b6edb481f40dd0f50067f6c9ff8d88707871fbd29bf276d2ffde921c10faf440268eea4db706070c3966f0c6e7deacece409e69465ed9a770f5e7bed04a0e75699e2cbfecf2e671e3631b5be7e90afe324934c9a9ba57a0791de827aadd79f1cf1488f7dddc3c46275e74ae95a5f3b855782201247fdff5e848675f5750873c8438e67d5dc7c4712601c1196822686ae264c75ab4c7e36eba61f5bf4b24803d77aeccb7118fac0a1c6bf615358bae603efecc0f73c21957edef93009a1b82bef4363500e82b86fea2773e8955769237fef2600eb4b49edfd8b1f7fded8eb9e307f69d4a06459f42fa9cae80ce6b11ac9b3feb04d0aa416802407f29e827579ddbbab5b72cc9532da0aefe275bb76e6b3d3fbae7af2301f98faf5ddeba63302cb9f0bd63033b46087a25816ac252d44a5c7fe8218226a6e496fd002909941d6ed3475e1c09f017ee5d35be465095ccb99950bc7f5c9360dcb6f08acf74760ae666cb30c9b8f8308b041041fff1e517e7bbf83efdfe45b969508ecdd78960dcf0605ec8e3e5a2ba3e0cd05d375e7caa36308b04d0b52d58d4aeb14800d05394c4b9d42fe6001c7a7a43eb1efdb4c5dd7d65b0edfbfed5ad523dfd6cff23df6bce33d28958ace61b37f16cfedd961ccce7dffdf3592780f2bbdc74f36d4a7f986d0da06cff7755e5a306901e6f7bfaae91c93ae59c80c3cf3c96abfb911062066039c3f04c9a00e3fa01caefe2ea434f65a99f87eba6269a40dfb7fe64c91e77f33555eb0d07f2bcfef8d9c1c7efcdfb69dbf783d5cdf3e307ffd96a228c1b529c7302a86e5672f1a1a793217822ffb1cf7a0420023d4af49c00c668d51e6658baebd92fbfa375deb92480fab661171f7aea5aa7aff5bc480065091ec156f7ecffec3be78f94c89de72dcedd77e2cfb8adb58868ea7700fae90ccc2240a35a7fecc4b00a5fcce8ab1340d7df08982901d40b7acc25f8e3f3d5006016caf5fa5240eebde3b2565046a91f6dedfa4f73d7c11f413d52b338325c3db84a00e51cfe3ec1ded5fe8f734900304badd23905f54357b5ee0368b5b38725ff9ebb3fd59ae25b2ef315413fb28660b9be406536f3ffd500e055d48ad46a89b0fa0f89441258bcf4d4fef4e183edd57dd69e3bd2699712c05c46004c0082b3a46e024422f8d78fbf9a6b01e51f150dffdef0f59c48e63a022001c06b5457bb3f5cf2810fbe62a5bfbf060caf43910ce63afb2f6a1f67fbdf03bc0a0922dc72cbedb9092201c0ff7913e36c7f1f000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008079af33ff0148dc1c0b009182b80000000049454e44ae426082'::bytea); -INSERT INTO map_tiles ("event_id", "z", "x", "y", "tile_data") VALUES (9876543, 13, 4508, 2412, '\x89504e470d0a1a0a0000000d49484452000001000000010008060000005c72a8660000000970485973000003e8000003e801b57b526b0000200049444154789ced7d79905545d6a7133113dfc4ccfc3111dff7c7444c4cab6d77db6d974009143b0aaeed0e8ab68aa2b64ab76dd32da0a2022eaded821bcaa6222e208b85b8f2b90082cde28a288288145a1bd4c256505415b5de89bcf79e7c274f9ecc7b1f02cfae777e1137de7b77bff79df3cbb365e65147090402814020100804028140201008040281402010080402814020100804028140201008040281402010080402814020100804028140201008040281402010080402814020100804028140201008040281402010080402814020100804028140201008040281402010087e040207e4a50a04794c0049c8f57d0b04824300add1cb2eb196822e3dc345484220c84302c044b0efcdbbac0508a2579f93c58a10083a2b01e0852a3f5e8ae7f566d71fac1591eb772310747a80b2412befb2005cee41d202a4109e3bbe0e26052108812047c0ad2d35f3d35803695a7fb57ef7ee3de1f79b468db6b6d17b00fcf9a69bf577ba9f581002c1612280ca057778ad011f11501250bfb10560b5fa708e96bddabdc0183fe19ef073dfaa17f5b9e892049ffb71944090cf30b421564050d6554f8d4a45041fad191b3cb87e5ab860a5a7ca8fcf8f090188c0b03c90f2ba141f9f5fe18af3cf0c8f87fb509f388029168440904000d4acc78470f590dfb10400ca8f1750dc3efd624554ad388a01b8320bc6b93d0480eff1e4befd0d3201c5c7a4c291d83eb04810e1084908f20ad4fc07a5d83da5b765111866bc830440013942504b61612f3308886b0d28b9300143bc0edfefdebd7bc3cf1efd07580a0eca5f7af3cf58e557ebc36d8cdb80d7978e39560842d0b9604874c95c830cd427288dfa7dfee9830de5e3ac81f34eebabcf018a1f9e375e3e9af657437907f6edef4d1bfaea0fc0b25068ae586f28f7fae7c6b06435a87f6f7bbd07981c2849e07dc48210fc4bc2106244000a6f3e30522b7ac5b8e32c937ae35f8e6689402b70c95c2b00a7aec315125d7de1efc2df756f4cb40800130ab6263041d17be3ee6bfd73638d40641a000194df5160dc8fc29e771fd7df3bda5a594b42e1a8f7f60941087e9ad09219b7d0b8b5a3a6365636f5fd853baf31cc6cac74cb9e38d732ddd531edfb775b0ab8b2ea1363c1f701308e71f8f2f81ef0bdee7dfb62bd4fc5dd457cdd0158290c0138ef839044e5fd034d9721856540f739186b22d73224f817852145552b0de16ba9fecef8cdf9ff7b670e0c9a179d63f9e3940cb432fef30fe1f7aaa99724128056464f25225e9e1d372255d562711c44b414cd410069ac054e693191fe581721c9054942aee54cf0138525644401b8d6c9150804259f79fb08960c2821e0c599628c3307d465482a562abfe5584bf1ff72532f238bf0c3e8a3a38742f109fcfcd472814fb5ec5a7457628ca06ed9746b3b05ac4f638dc0be65638f0b0efcf079f063906bb913fc44902428ce16a7646e70e71f2e72660440a9b9009e2ba8c72d0adcf196d22f1dc6b6f8354f9dcd162415a3da8110bbbe0ede9c3921686fd813feac6aac35b64fbfe4d7ce778295d8d74253d7a0b9e26bf78baf5cc25a23f59f2e64cf9bad65906bb913fc449095d450e18f5b29acd0179f759ad332c001c26c08c0691d30e7a4c1c7a402a29ae7aed38f435b619c06e4fc7af5b9e79d47d9f75476dbf1ce7797e41a9cfcfee5fadafb56bd64bc6bfceecbc6fdda6b59181616f9ef722d77829f0858e9214204e8686d3677c0428988407dbefde01f9dca0ae7dd36f1d7c9caad90a0f42ec54ffa349ea9bd2da0a084507efb6ff5f76c5adc4311037041dd6373e586acee49ed976bb913fc4480054305df68700e50ffc92bac2019be73ac98aba69e67b5f2a70e18184c99fa74aad69c1240cbfbc39cae04ae31e0cc7c5a35e80c002a2062d19f41109cb7fcfa60d2234f8464c1a534c3751d1d810f70bdd6dd95893e3eb5027ce7a3c7d0752ee45aee043f1160a108a3ef59b66e4d251f4782c7104168c2c78aa9966b879ecd9af9d854e55c808dcf8f658b83a852cf7d29b9bf406207a26511e9b42dc95421826251e503c03d6d7b60107fcef656ef3bdcf3ce63d6ba95b54c902f260cce95e0ee6bcfe2878ddf2d3525fa7baee54ef013812174559f242a7f7b639db50e1f83d378d03aab22185d08533237a8b8e3971982e04c7e627e83a203418135004afdd22c5ec1a9d94f2d040c4c2eb8fe41a161fd3b018783c9f157dc659717ab77d3f8ed87d6fad3978e4895a66c6b6b4bf4fff1b9c5051058ca8f01156f2e217601940b4800fbe44002102907252eead9c7524cae8a1084991203d7d2cf9bcd13009046b18300707c2204ea8b908434a93e4e9171e660db83830d57825a1e1d07f6b30490066d7bab33e7110b40e0220010c696daada6e03241327c0c675e2b85c3eec149dd23c5e3f6c7c781258249805342acd09cc25365a7560040672d149aa33420b6484ac7fec2202285d65d15d63d356d5e19648b76a5d431d4b977bf711fbb9f712da65e2135f9841d9a7e2e2e80c06d0180d074b41ef00aafda0f9469e60c5301b5d096cc0dbe78fb11ddfaab65f263a6c29ed423a398fbebb785c76a0bc20397b2a78d0314901e881d28f0e78a3924c157069c4d36a06d5f6df879cafb5718d785883f777e030e4b018e11f9171cf45c005ac04ae61a51759f15805d00f589c9831e07be7e086412e3821b0810521f1f7e9f7e669197040ae2fdc68ee965c4200a0b8ba2effbe3169ef414fc31ee00d72148756caa9a32cc3a5ea17ac670cbe5715dcf470056fa560840e08b0150c152bddc30aa9ebad8e838a414e9e4532285e3ccecd044fde6994c30b0646ef08bb37e1f2c9cdb3bb8ed16b33c375ce6f70bcf0b5d7de13ee0b7faac987f3b1bd84b6afd217d588c89ca517780ad190c5da4b37a8e717ffa73f431a95a7ad529c9db4b90e949e94b217216475bfd4e9e8c04022a2cd902848e6b5d4179bedffa86150cfcb717d787df17cce573f4600540d75f209a1d3b76ea7570dcb06119e2a1f731fae60cb974e996092cee8b6307f8fe6981912b58084893a34f5ab773c16ddef75b3bfdbc54d708a3fa8448c0caea6869b2f6952c80c09f0598d08d155a9769eb6a81b1a90eae008ef063578023015c68046e011083afa59f32d92623bc94a2349f5672a2f85d0bed602185cf243fa8cabf94117eeebae3b798f11a9c76150b40905803c0293b85564802aaf4a0605411705600145c09aa2b50a70029c56f1e3d23547c381fdeffeaaba3fefdf4786c018082abe3bb9d94d9f7d9e9e932133e65f4a5f9acf70a3d103def76f7ebf71ac7c2278d15d0f373ee04fd0fb63d786ab4af20bfe112681098fdebde0a828ef668982d87d0b57544a9415094472799fe3c26011cc04b13a5874f2080e99b66075555d5e1f7a463386b84ba2b3d8a92e307b0eed9198ed26146097ddb77be32cebb1fecdbf16ddc01c80108a626c241420d4b270801e43ba8acd09417155e0e5b2716869f9cd268139eb46460fee363c015a08aac3ea126009bfe9cabe10b02c2b36117a4a04bcfe08c844c01fe3cd1936b9ff1f4cc303df7a34c7f6459a973d42db8dedaadf21fe6dc8bb4304a61cd9eb6e0acb58dec6520ad1b066305f98d6c65b4754f949f07ec78f9e65029bfbfed7827018090d24f2e124f490057f38115800980cbe7bb8801be835f5fe068f95da9495f6b4beb03f073fa8281a192bfff64f46ebf8c029ddcbedc799a9a9af4f613bb16b1d7291fdf8dff1fbf981a7ee65afe04394690055cf101a594adb11b80837949be73475b8b55c5b775ecd1ced618f7fe73b5d4341ba196ebaf33630369ac8782838c03a445d377abbde490442080baa553acebbb52810662a2c9b5fc09728c54d25a3237a8997583531075efc138b0b5e9dbcd46814dd396d5c6fe6d7b6b0c37e3b3371eb614ed83a7ba1aca09cadfb590a917408aefaa09b8e1fa680e027adce96798750b2e02a0486be22795e6bab6e34c05ecc75517e24c061cc79ed79159c8b5fc09720c2c50d9949456dcd5437fc701392cbc49a633082f7417e65c8157488d8002ec337f8eadfcae16dee7df1713e200c5f3b5fa3ec5fd31604dfd2d6bbcd7c7039458f716c715b8de9b0ab9963f418e8185c119f473f8a5b5cf8f347afc6165c12d2a27dcf83b98f7d40f1fb9e85e43518d725c52acd3b79f6dc2fb5c0557c6e0e517edfdb9f7e303b71fd77a574fbf2ca87e7ab85edf5cb9319565c1590f69c85bb95c2ddbbf3536e55afe0439864f50b5f0c40254357988255b49e9b8f113a35cb68b14708acdd76ae3e3b8021ebcdfa597f2517dce4228489149c84ad90e113041d67f3c2ff1da493ebfebb85ccb9f20874855d0422609a170290e98d0aa65c7262c4e59d14a3e9f2b404900c7057af619a8d3849430a845908d9b50aa9e19591dbe683efd9dd60ac0d81517fef8f649b32d1b8802e6313881e85ae84f775180125d3d2813b4a38450d8bdaf750c74e8a14ae94a0d72d60028a9527ea53c94045cfe3dddde87711f8c7101b368790174dab10365ebb23ade0520448c337b9f649c2b9bff2fd73228c82192842349903a3ada8cd61214684eec47632b40012c014532789b2b9f8fcfed33e91599e0aec3b498c8a7fc050e8b20c4b24bac20a04b61975e775cb8af1ae340017753ce0607d3ea6b921a7d4caaf3608802e631d20a581af31f2b9dcf7fc7e7f4c50ea82bf05fdedbebac12549fc3affa833eb7ab52d0e5ae143888c12204c7fb488a19a86d6d7555de777ba84c7a5510940d722d83821c228d807099014e59a842d3df14b3df1ce155468807dc3db1175b25f8c0fd99bcfe63937a19f7aafa26c031277675fbfd0509453f789bbaee17ebbeb2de8d8fecea9666a62c3708b0646ed0f15d348680afeaaf79db37deff868b3f3833390e8802e631b020bcbdf81d4360c0a4861969f03697926fb9f59860dc6dbdbc0490d4f2633201f7007ebb5c018e645adf3a3fd175289ed73bb87d1c5f58c4d5052802509f6a162197a5004be3a61596d5a37fc781d59dafdc6e293d1efbd0e73e50e53ef0fd67fafb9e77ede1c5cb6f3fc138168ecfb50c0a72082c9885ddfb580440d36e54783885f2b5faededed4e65c423026197826615e0fbe053cdd65b551cea6baeb852fbef2e85e6eeb7df804cfa90db4711c081b22ff5f3a8757f1be52610a7d292ba8a93869da2959f535257de1f176371c14ab52e69887751c03c0616646c7a63658914978cc7170beae299dd9dcac5812ac9c81bcc517c70cbb7e0e548d17da94197fb8023f8ae16da678514cfeb1ddc7b77e462f862014046ae202607bc9e8b8bd4d4d49a44e148c17af3fc6476223d474310046b77ac0fb64f3a536fcbb50c0a72042a9478a11d742c218b09c06556abfd96bc3bc23866df9a97c36d9b6effb9d54a720a96363588d7abb261b87f4ef1a992de7bb76975f8ac03170178b308e8bd5185c5ef7bf6e5bfd2eb772eb8555b40986cb9737044d0de94190ca4fed3572c02c0df8500f21886d4c4ade5c2797d9c0a41050e17e7b8148882db971209b73fb5328064364cf8056b81706e835a7a16f903800509590c7c8f5cd191f5be50eb4d3f95a2d2ebe1e7c6649b14cc2b1d7b9c4506e513a2da00ba1e4800d6e75a0e053f110280050bf43d77452d2417944a8afaaba58c114c9702def8a75e4e02d03188145582679e15f58b4f5264f8ecded3dfe2739644d2f3e896dbd1032fb456e69933216142a4ef8016f8503f9fd600640351c03c051548aae8607a735680fabeead102e3182594578fc804d168100b7fff61b4bbcf3f86da77e7a2097a9b2b1e4095936bd5935afcd2785c42f8e448c0e5aa40ccc2200847072a38f7a4877a25c60de03c87aa4640a1ad7e87f13bd77228c811b0105001073f1a162af4f07bc040dec7a650ebbbf7ec6face3949f13743c74b7faac2abed3509c6fee8c2af0a8d9cfb6aeb19503938816cfeb1d2c9ddacdb2247c1640da5841f5f42b5805c4e4e2b2008cf7402c89e285afa52edec23116174401f3145839b1d263254e0aa471bf55e10d408dbdc7cd4613caf52dc73863021834a0a73e2bd7cc60c9437d762bb4e30178ba709c1d388acc4cc43d57521c0216170972d17ad7f5a88b8509209b1261ce9260cf2d0490bfa00a76e7ed66da4b011303b602f0c09a5880138514b766c894e782616aff8eb6e660ef87cf59598934bd065de7c644534cf651038cc0776e98721c23f129f1ce9dbb8c67afb827aa54843910f031679f5d142c7cd974a5a89252858661bf612cc1fd5fbc117e36343418dbb95a028a5ccba12047c0c28505ba65cb1c3620485b375f60cc009e6a9b4c870dc782498ecf8f5b404c00b44a909280cf2c9fb6e002b6853f11950bc3f3968c3d46ef8b53672e0be8c221d1b8839c15b3edd1b38d3914e9b5d4f74d77fc5c5b4f56ca2fc54421f4fdc390e1adad99d99c39821105cc53805040cba6bbbeaaefb5711560bc6eece85ec1aed726180a7aefc861468b8d858c120040296ddf5e7d0d32e0cc782afc5c108e6b897da5c25cdd40414275a05694f8fd8062ba028bf0b9fdd1739c8a8a9fc545ae164ae6ea8c4a1ae5c7014dba1ec71976bd2af302e425a80059828726c8a46e80fabe6bd76eadd0d0ed150b38a0ecb65f87e7c1014510e850383b3abca63adc17a7f4aed420083f2cdcb1aec05e31993844ad6faef8da9c30941991889ee3fc7ed13c091c28f9a42580b4d385c1fda5d9267500790a2a9054f8ba74eb65b47cae16f3c1afa6869fdd7bf4d3c7d24836b5080e1c8883826846617c0f8b66274fd201038596ddfa2be730e19c62722d6fb1c38d31ac9b98c4f0acc1be3a0857d51e3eaf8bf83894ddf24b3610981477a10aaf8083b24200790a2c104ab09542c1387a2018edef0cb5825ed80ac0a3fa700a0ffddfb18540c9011482b6cc2ed39ce6f8e19ece38f5542701c03ed872089f13115b40a705e786ef426e00acc3b10938bf22b8cb4fee9a3501242973f8eedadb9ddbe93bc6cfe02b21ceb52c0a72002a90b4e0a7e2deb867a0a7f5779aab6af0d0aa6ae736ce3248d372bbee03fbb5d85dc14b6ded0ebd9fca5e84139382322fcb287e7bc31e369806f7ab63248800e0dd29bcbef0346d9df80840cd45c05920735ee00b82ca27147aad8aa03d13e8db1677f451053f9870b9a2acf0f904f9072c8ca030cb27df640c8b4d959d92848f00b848b873bf14413e4e592829a8fbab8e8b847cd7a7ad793131bf437240fb6a855976a9150cc4d60d7523e01ee8bdf808d5471caeca42d733e26ecbbe63722d8b821c40fffba4cb2c561e2ca4acf2a39690e6ac71b4dc07c8062c7dd71c1d88537adf36ad8cb1afee1c160bb5fa2e7701e3c39a4f0d2b40b93cd80a700540070cccb85300b03c5cd60c7d56ab0e200e049e32e82ce3bcd455e1deb9119855f104b44e14300fa1a501f9f8e027df346a74f05af16063bd4b21f7ad9ea3057068ff68124aae052e1dfb0bfd7de782db5801c69606065672ee3ed428445a1963c5c4d5885801e17cf8b9bb74b38905b0e7bd27f4fd412d40580f80630610a167ea1bf0b97cc147572c8092ebf9679fc9b6e24d5b33bdfbe87bddbbe259e3f7ae4599d18ad567ae655190630278757ed42b0dfcd9bd6f98813e4ee9c296765f946756cb9ec50f6ba1a2814210342ab458f029112965da35ebd220d83adf52143a028f616d20ab0493d0a2b2f7ac6baa19824b1da30e016aa79eaba3efca0ac004a0af172fe577fc26089a761a24c059472ee5e708168e7974e86fc2ef9fbf398925000ef8f9cbef28b0d601698902e6190c29212e0028afabd5578baaab87e354a49f1370ac0000ec5b730a008a446b065c6e001783a06e0c3c93ba3677bd62625950e27005040d0b205e5a3e7d3c18bce40aad58f85db8949fbe2f1a0bc0f7a2dd802cf2fdd41233acb3f81e732d8f82230c2c10a1b2ef2db5cc7d5f6e9d4e9841e1525857959e36bb63ff1cbb23ae7b7011c0b4b1d1588054e0617f3560a92f96900661ea932100b5842447dc01eae270caef2307055cf464fcf6e0898b7e9dea794401f30cf8cfa7c13fa578eb9ebb20dcc629096ea93901a4027cd784cc08c13e21c72de5fefd0d5a717cfb73308a75c8f057f8f8a79e30c9ac14320364420f7c1db58f52f0d00aa0a5d3f1d2bbff29d63d9d73de45c1ef170d319e09ceaddc105fb013bf97f9f38b75ab0df7437d7ece2250fb5754540a01086c0280d61e0b3df5e1b13056bf7a9121f0b4d0842aadaf95e308012bb26b1fbc5f4bf51643e8e139f03a3cfe1d4738a537ff2cd87e57e463c36f7a5d38dfa0d8c4ffb7f77639ad007cbdbefd076b17a4bda14e5fb74ff7683055974584afffdaeca2a07bcf7e3a6302c5576d75ee5a0b0e2e7741f422cf8095055a30ea33bb948fb678d0ea34ac7f57138aafd5e65a7dd50a52e5a7e77af5e54ce0cf073c8f00167c4a02867fbe2c6af9e11dc035381746a14b9fde9129ee20002081b3cebe5093ebea29a38cd98ab81a04b53cf9b8e9a2c0a28a8b42a48801187e3e5ae742aee5517084a1ff795ce67bd52f838b2e8e02654002ef3cfc27de1c45821e9a9713bbdb39eb04325043826105a0f3fa4dfcea09fdbb6aca30635bc53d7d9cc28c05bd69f34a56415c2d6e01130fe06218e0e76357802300985701073531e17024c0594c383899542a4cc915673930b0c5260a98670021f87e8c392e1f4dc5718140f5bbe6c1aea60fece9a586c9407d5f33e9b79692d161aa413033427a342bbcbeeb9d32d82c96291b17cd8a53bfafdc6b9914c4eb5a5af6ebfb7611401a2b402db5af8ed7ef130a816889b5fabce61a738e047a6dacc850b28c038ca70c8a8e7f78c86f8c73a8b10ad5ef071f7ad47a5fed4df54200f906f8f3b1e273158174141e6a016053360d7af688861ce75a5a6a0150a8d6162ad8a8b20354515273c57abd2d2945e6b2501470ebabef33567a4c00f77ff629abfcea7ed57e40029800d4d2b3973f0340c9a147d100631d4cb7aebeab9e9baee3d5f2fa4b45e1ac4b91bb158f4c14a37afae54200f904fce753731e778a49430055af46bd05716aca073580882bb8968139a34d9af3d27d2c6288e736c4fb41402d406309e0083d1ba0c4157f3111d0ac005e77c2fb8f1a2400db60f9eb28be43905a9e9d6e12126711c0e2b266b88150b877966b99141c41181240a3f9a4ef3f978b574377b908c3a7ac6a7d5b5b1b9b5e03d4be7063a2a2ef796f327b6efc1d9bca7bde9f6cec53ffd668f3b892cc809b2ed7005b2a1c01681220eb5cca8ff7c3d7a3ca0c240cbffff9f88989f7e8736fe0193011480c20cfe02200bc284199fd3c4f00d865a004a084e98221767d808b141a5a1b83c3012ee8e51c55b724a3d02eb7c0b05490f2ab6b80225fb7a1c92001aec507e50fd37890cf672630559f975fee1e1405fe1f6c01f894df726508722d938223084e51f032f4acccc83e9c0b400900a7116937588cb0b7deca175985a5052c559387242a397e067dbf2b5ff056c8d1eb6c7bf8f4cc463458e79b2f30fe7f6c8643ac82536e1de073580861206efd344d00b81b34753d925af8a456ffdfdff99e2587534f334b8bd5220a9847c00aa10462c654330d46171f01701d704099b0d2ed78f96fe6f6947e3da0e29ede661d3e41d92d514fc3fdebde4e2c910dc7f743284d1899887b7e57eb0e0400240016c27f7d7f6ff8fde3f5c59a003812a069bf6c0900968b5e9f91481478c9b54c0a8e20a8b073cb9021f694dd3e0b0047ce2901508070a77113d47c0069c05502faf6f1a198280926bb2402c0ae01762da0e58782244a02931e79827dd7aef2e9b40bf7cce5713a14d0d1d62204904fc08ae25bfaf7e9979a00d4e7880b07580141ac789424583fdd8396aacdc66f1c74dc3127b230e0b9307095a242e3a6154e57a51429f9e4c7ddfe741201a8e57f4dfd304a13ae7d4a2bbb3a0e48407d0229e01184e97560e9ddbd8726cf937af0750296a51604c18eb999a027fe3ff0f3e75a26054710f8cfe722c57899fc189f5fa641409c41a0053d2ee1ebdd775062ab5c76dbf1d63a971b80059a4e45062e02bd8fed6ab28e204e79ae7a49af4f6a597101902f0b00eb3001c0b2f67ebb8556f7b1b5a15d2bbffa0dfb735601a7f4a7bf39cf5d3588c60d5428ea3d307aa782fc00fef30f36b8c4590040005860312aee8ef2dd18575c79ada5903ee07d6a665ecbf67ce3cc7fbc0f250fd8b67fed6b5667a67bce3b21bce7e79eb65b585a01d8f7c90b83def79dabcf0b04a0941e93002600ce5d02c5c5038fd0de8cbe169fb6feb802d2875ccba5e00801ffe9d928bfafdc1508405b01cacfbc3d2374567c00adbfec8aab1385932aab0b4b972d0faaab6b8cfdb95670fc847bf4ba70a69d12530115563ff25bd61f37084081a442f1b5b09f0f24400900d7216097062c304a000af8be7c2460bdbf31c7b2ef52828079042c103ed31f0bb66a555bdf1fc6fafddcf2cdd39769a1c5730680e0c1a09ab02495ebee443eacdaffdaeba20e4a9450d47d669359c02820454c58895c0480df01bc43b54ff58c2b8365d3ce3508400f248a4800de4dedac1bd84226b58f3104590c75dc7df7da43a2a94f3c0b521ac0d461b9964bc11182f1ef7b5a75aaecb820e83f1f39c7de6f69862070892a08396d1971eb9c4a696385a3c38f2918bdec822068ddb32d9592e396b194993c63fb63e7e9e338378823001551d7e78d9f1f130090809105c0fd164ae606970eec16d42dcbb80bd41d80e35c517fd7735254febdaf0401f30d5819eebff1d45404a0070b716cdfbe7028db030e843514c4b6282887c9c1056ebb4eab3900ad3f775eaadc783f553884c11defac85502024a9d6cf9b6d8e208415fd8455fb0d02a0d585b85a11bf4720014a28f49e563cd9c568fd5def194f7412fe16e4074000c2012aa69d97da0a48b210a82facf0cb95fb0dd3972a05164040eb8e525b5ac990db9c6027e5fea10acf5cdf612b488939ac378eb8abcfc2eea6d5829510ce3b7bce3cfdbb6ee954eb9ea026402d1d1dd13de0774453944002b3beffd28829d0414e0dcbc4f32e38e45a2e054708580869faceb57c343d4ba2c08205d35323056e2f9917acaa5c1d7ebfeaeaeb9d42a9f6dfb464324b0054a85d0400a0b1082f4ae63ae300a0602f3c670e27063eb87eb7f1315b6ec904de001010846b1d58f90fe359b8e7e3622a406a1c01d09882cbb2d1e712e407f43f7e085a7e973550ded46108292e7985c54883218154cb7df73f64092b174bc0bf938289da1221c759e76c6dd084050af5d1eaf14179e9fb564a145ff3da6b32039f2aec5c706bf8b9707e5f5ef1d0bba8b8ab875519499f0b8a87701a13ac809e9f375aae49f5549388431002c5f7916bb9141c0118ff3e9eea3a21aa9fcd12469449ae5dfbbaa8d583a05e52a04aa7d0c292d5565e696361a6c53e58515c50136e52e016ded9fd6b00001f11494441541cd7387c3df1171609e06328392870c1459d41f0c434007bde79d4f92c60152802504b6265254e3712421605cc03f8088006fdd4a76fec7b9ff97feef2eb0d41fd7fef15db66fc9e4d5ab97ca5c13880c60114ebf49307b2cac6590e2e2b22041920c408f6c5a0b977ce0cef5a18d70ab4b7b1e70feabe0b8f7bf3adc57a13cd1640f9327e27d0cf00070881006081c9572c32a85a9509320a01e41f0c6170b4fa3e4b808b17b4be7581796c2ca48a002aff9ee956acdd802d2f87bf77bd3a21dcbff2be7eacb03a27f644c08ac5c500d4ccb84000ea5e3812d8f376e46e18d178c6e787edbb1747fb0f1f5ec412000e1e1a66bd278ea1e1480f2a60420502806d94003a98119530d47103fa0f0cba742d0a0a4fea251640be000b812fadc71141a28be010ecf603f546f10f8e747ff9e557566b5cf98f938ddf17ac33070ca1d5723408c859101d2d0782969a2de1ef8ee6c8c777f65728c98c0980537b70ad55a88a8f9afc9434f67d947927f85ef13d1e77c5cd5ac1215e02cbc8cf5f0db7addbfd8d65fac3fe004c00eafc8a845dee15754f56aff9585c807c8021058732f847f2d70a60868290428bf8eb159b2c4184d61e57064227209fffae00c391bb7c6ea54815134f62a3eaac829498e63f8ef0ab45c50cc0f46ef872b1930470d14ff439cfb83e3cdb4ddfd4b13113fcecf45de2e3d582951fd6e3391829b83841ae6553700460084096917d974b0042defac5142de0ef4e3c2d68696fd5c2b8f087c546e10b2e4251bfdbea7718ad63fda751cc00e7f01554156012d431ed8d7586a2a973aace48a950927101e0581cdd876be0efa0f42a350823fdea40203358aa5adfdede1e948e3e267cb6c14baed4cf889517b7f2ea18bc0f809aff40020aad1d66cf3f409f7e83ad75a28079002c8069027c350f7439a8d61f72ff58908100fee7921d160160731e47a82901a8dfff7745bda550f8b900bb5ebbdbd8d65cb9511fa3260b71b90005598c4f90e4021824d0d1613c230c7edafecdf3c6fb50cbdabd6d96b2e3c01ed7f2e3851e935479195e47d0f96108798a009f5a1ae79de9b4067034192bfcff79efbd50082f5b74a16ef1ad9e718edc3d0e9a85db208a9e226586038170de8ed603ac127065c1690900f6e794ddd72b0fbeeb1183144ae606933f7e589bfdca1dfa8f71cf1826bc7a5f5feff92edc063ebe4bf9810070e5237d56ba3ebc86a0f303feec6b87feee47f9fc9a04482a090394fcd295a3c24fb6671c0ae6d1ef969096cc0d4d7b2ab818dca4a01c5cad7ff99d5dd872632e6d88fd7cfadd4706c67321f2a4053e5cece3b4a55779159fc601b03b81890dca8f31722d9b82c30c2ac03fbaf0872a7fac38d0f280e0c1a018ffedfd3d862560dd0b41d96d645e7be266704a0e0470fe85c3bc44e01bb12850cfd1b4d338b675a7d93fc165eebb4a72e1774df567e1e71baf9e61dc1f10c0770dedc675d4ba9696a877a1cfe4f7c5013091600b0016e50e85fb093a37b060e916f830100060d092e1e1757aaefcc230fda91ba05a2335e51555300c970feb721fdaf6660605a9bcdf3c77224a32cf52fbdc757af5ae459998824be931d46f3aa4ba829a1791a603214dca45f9716624adf2d3fa000e6201e419f09faf84edb9cdf383a7c60c3f382240d5645cd00eae41957ec1ab7f34841ae39441e6449e00ce5fa582cce5d7e9f149e700f84a74710bca95fde2fde0936611706a11ae478b7f6800b0b5b5f5a02c0097550056d0faf97fd5bf732d9f82c30c56a26305562dd5d54306787d7e5d3844eaf9f1f2d0c38f063b178cd32d1a1000d7f2e33c3c47083061874bb95cebe977eeb70fa5a38fb152775861b9013a69ebaf50bcf035e3d98008cac777b32afaa0b0e8dbcddfe977f4fcb68ce9af10b6ec27f5cd8a047cd6011d654814b093c3556e8a3bea80a04ebef90a5629b99af6d04765c6b45382fbd8e78fb1a63f37cc151cb7ed91dff18a99a2d390c2b4e9cf5ac79dd4ad079b8f7761f5c207c2cfe6ca0df63dc4e7c1a310b9b21438bd49dd04d846c7081cf9a751963b0051ffa4e87f360b1d9834d7f22938ccd0d28426a9d08a93a64e9d02ed8ff3f9586841d9a96feb4cb565a1a44923dc2800c184c5411b6785df6b9ee107212d1d7db4615de0341c6775c0f36e7fec5c4d00dc7d53f3df02b6b6d035300960e53f94242069c03c8216382e78e709e62501c700b082e8960591c09a3d66659adae7cb2fd75bc7d07d28b9d0f2622e8f4fad0b96743acca83bde57a5dc14ea3f59103e5bd92dc719750bf59fbca2f7c7d72fbffdb7c18ed9a38cb262b5bdb07b5fcb0208415d2e15c48c67505624b0a6662d6bc61f0a02c0ef24d7f22938ccd0ff34a3f0c63c75681fbb9e9d278cf66f6659e441152ec91ae08a8200781c000cbc7f7bc31eebbabe7e045082ccdd6b41fc7b43dd1636c08995dd027a87d8f4a70143573116b6ce70e5e0e122019dbe15745e1802ea08f275ebd9470b2d55769ff2c33663982be202b888c0575f4f95352dd254f201681ca29498fae1e7d6f9de7b61890b9180d52f00b94d6cd0d551b5e86ac1850004591100370a1044eda9025b796e860cd4b18ddf4673e041f08fb6c259c5013c63e32515021df8e1f3ac08203ce640542aaca0fc7e9d9d4870855a77575a590c1f51e1d25ffd9b517e7c3e1acf385c968082a85127061544ba28e1c34354ab3ef3ae9a769800139603abe3f1fb4835200626012e5887efab6ceccf9d4a94c612f864fadf0eead8a3e0de882b43cb815d9991f5ebed8c81f358577d0502b80238b3c28dfe732848e0d63f5f2904d09941050bb7ee9800e87e9400f6d66d8d36c64a32e64f57b02e83a1508c92b97c73b8b7e6ed9931035cfb95dd7a3c5b0494343a30061cfb51cde7ba85c525cc4916075db7feeb0dde6328e9c2d2b1249e548560d6ef3313a3e279050f0709e45a460587112044ae4e2b6b9f3ddff28971000b17bda801387070b0f28141d1c94be6067f1c312ca89d728e562697c2d3de6a003cabcec1421140f3b68dfa77d9adbff2b6fee1b6adf3357135b767eea1a1b529f57571818f3e2f02cd9000098484b3ec92a069c199ce73e36a415750f0c7928028602706081267d64f9f12cdf40bbfe94cb4943014da1bf79ad17132d0241df23b4d06802a4ddbdeea64c565be2b02f076f6a16022fc27c756404bf516f61af8d8f60d33bd8141d767434343b43389c7d07782b77ff2e967fa1c053d07b08a5cf8e06c2100413a02a87b63a20e0ad2969e1be28a53004c04d03a715584afd6b47ae300f07ddbb6eda9b30314554f0ecd8a00c08a294d4134aefbe0decbe5c3af619f0dbe837504282bab30de595280d0170f100b409068015c714591e1b74fb8ee62a77bd0a55bd40f80f5ad3dc54469d28023aebec1a970808a0927a64e03aa6baafbfcb866ad7f4712e83b99193f0fc716b8eb377db7da79cff49ee879d9fd5de32e78c625389424206ad349810506143b54e63847ad3ea1dbea90211131e0a215507e1f091415f50d36d66d31320420bcbe3800ecd3b86fb7a52c754b9e0a3fbfff9b7f32515caf0ff7eb0553edd8b6b7d6da6de7bc31fa7bd92dbfd4df69f5619285c25943b01e3e5d5581ae67debbe259e378210041d60430eda9682a2b501aacf4f0fd8f237b1916019e82db5906acfce2ef5eb694cc9701c82677efdb77e6732f6a178016f8a8de79e1b1db57e8f51beaf95260405a254fc2886b6e088fdbf3ce63fee391f2afbd313365b9cf3ac19d7a240828f01240cdac1bb4c2636587d61dafe316d60a4870015ead6e0dce3dffe27014dc34a9400e3be78fd502ef83520c8e0070abdfde94b96e5b87bbb5de36298acabb7c797ccd860d4b83f23b4e64ef099e73ffbab7594bc68a27904e4149e9c72fbffada208170dbb24b821307fe4e5c00815d038047a9e108809afe46341af2ec4cddbf550b102f5cd0efecb5e6c0958d9b96879fb7de363e95d0d3f5b0a8f1ff9d0415a36cdc6f0cc5c4e72920e67adbfe5dc6b5b9cf34fe3f9c1bf66dfce603e3baf01e74f68404fe7cd783df50c188976c2c02d1954e0a2c2c2e45cf08dda55a09060f2ecab4f8208c71600a97cad2ca40ae672156363ada2d561417b0d0ab4ac131636f77ee6b0c0ceae8a30fd7a4a94a05b5aea56a7360ee6f9a0a49a63928dfff5e561f543e303831cb809538b45c98ca40eedad47aa06e4f3624906b39151c2668898895970bf2d1a9b5b4727c38c2e83ba0beef7ae1f260c1d4710601b85aff7e7d07e873f5ee3b48b7fac70f3c475f4ba1be7ebfa59020dc35f1b87cca7fe694a1a9e423631d67a1a4c1d25d6dc1c285af3923eef4baf43b5dc77578c24556f0695d07297faf3e9969d2d47ee184a39e7bab6aa8d5db66cd7a49ef2b0490c700e1d0a6314a31610bc0982b10ed075640bf3ebda2759fc6cacf2cbad5c50387eede6044fcb192d3b1f028f62e7f26f2ebd7bcecf58b413120a34195df525847bdffce9dbb9c663ea774db1e3cd5791d0adccabbf6eff15183b36f003d175829f81c679f3bd43a67f9b813c24fb100f214200858d17130cff84d4c7d6b0251b4cd6bf693cc80fadcb52b4af5e121b0e8388118d42d80df6aa20f6cc673e31bba94cf382e0b0ba1f4e69f19331dbbce89ef67f3985f46159304dc5068f89ce139568cb0881aef93144cc4bfdb975fafff3b7101f20c5820683e1f2f38f0e4b306f0ba71a3d488391dbcb9ad0619b963a421b858f13902e0827249c1402bf8e8e80c944d9a91031c5f357988bb12307e3760ede07db88938387ffdc00f9f65ac253c5b932703e182ca76d07b1302c8335852415a141034f539e92f97395b7c8e0870dedf591a1bb7ca9cf26302806eaf2e57c035932f557e9600daed0932cb6ef9057b9da3125293f4398d7b42efa6ff27d114e4bef325962b133700dc1cf8ffb2a93e0c767c9e180bc8b5ac0a0e035c0242dd0120021c080c856fc5957e2260c60234102b26a7fcc77e184d12fadb9332a6352501507c2cf4a5371fadf7ed56d8cbba16a71c0d1b96b0b747d3733e70310c408fa2fefa9da86224d7ac3c0ad5d32f4f740374cb8fc8d667f273d9808686c670801440edf3914526049047700972db928ca98ffde6cbce392314b80d2f5c68e6a35bf7bb63018ede6fa1b04fbb2cfc745900502c845d001c14c4fe2eee61480b943a5a9acc917688d2d2e7e714a9a04bcfe03f3ea84fdc9fa2e747d1bb09ef1f3eb340f3f66ff90dd4d24a599108ef160aafcaefcc14287db1ee2b2709e45a560587015438b0f90fad4cf8bb25125e507a3c50884104c4857862f214a760e3961bf6a7a6ff575f7d1daeffcf1d664fc190009aa32ec7506710160c6d7cc908e019710286007cd8fde63f829a6746e8df27bf7fb977d83297c235413921bca375f7b3efc23866eba7cefba27e3b770f90c9e0080193644b4d49f81dbb596201e4112cc18a5bef8eb8b08506ffb85a019c0aa402990de0383c510825085c1da8b6afddb65293c6aa8a55fa5c5dbaf5f25e27bcdf7d766b5e7e6757af6216383aee2461cbfdfd82a0b9ce9bba7301df43d594611601c03b579da35c24d4bab3cc2076fcae619134601ec21032d4bb0c2b1f081b56704c00b85ea0e98b4586e06141c3c259710fafa0a0d803567feccd00e09984b0d500187fee6f133303be7420775f30394812a08cf7c0f7512bbefa870f585fdff51b035ca7da17fe686fe402ae09a0fba94e48492dbfb8009d185830944274e99a51585aeb8feb00d4ba73cf2d0a46de60f71854c073cee3812bb1d057dc5de41454d774619408800068c150df1ed1b064702daa04ceac440aa5e1b0ff8b37c2cfd231c75adb70400f672b92028bae6ac0eb36a061c81cc385d30a43d7b3abef695a7f8901744260c1187ad6a9da77eeddbd87a1fc2034b414f8febf9b93594085dde66fcda0953172ad63445faa64a0f4bfffe41da365770504c3996cef2e342d97d8e7ef688f260d01c15ffc9fefb285426a6c404e49d2a29474b281fb5530a2fa4cce1f9fc38734014475ed033fb8073c816bc0e8caea77f7455f0901e41b0ca9407ebc95fe8bb7d3c83ad76390ebe84373f7ba45634a6dd5545c6035d05800e0a22f333d05613bb4aceafbcccdf3a21d1d05486a01331d2b267d3edf68401c7c56c5cfff596f74c755a4946658324a08dc7c84034f393331b38141b7c3f38a059067c04240abfe0c452013535202b066b58923f2a70e1a64f8e81ab82a2faee1a700b31e2bb96fe4609a3d80eb948f3bdeaeef47a490946f57a83db03bd8fbcf59d6fab4c0053fd8ba705ddb55e1a8d0d476e0a06a1492ee4f08200f8185c048e9e1e87f478751fe4b151f0f0ccad5fa2b655cb2f543c32cf70a6c3c1927f5eb7d7100f51b5a57b5dfe217a3da8296daefcd0e48ccf4640aaa863f4dfe1c039bfad8e4c7086b0f5cf004ef5a6ab6641d2c4cb31da3ec5673083305b100f20c5820684f40aec71ff6f721fd672ca0f85be7859fe3fe72553079e32cd605f0092f2814ed0b40538198088060703edbc88513e251d37ded5dfeb4713dbcfd60cc7ebabefee3f941f5d3571aebf1fbc4fb7248a3f04b972db7d6a985debfcfaa48eb06e45a5e058718f4cfa74a5ff350d7f0b36bb79ee102eb619e008e0042451a738c715e9789dd7e60bf57b8153802005022a0e9c0672b9a2342aaf9d86af593fc6457014da9a7971d8d1f505279fce6d32d978a5e8b062439fc8fa57620d0a5e0dc3a6a41c1c42870df4200790210002db49f8c61153b292e1082f1af719d7fbf01a75929b3245c7ad955893d02e99c826affd5d5d1e4187a5fa65b2f08fb8e399979025d69438c524e59c71ec76ec3bfbb772fb2ad2b725ddfb5f13e8d71652117f8c39908eede3119e3d8899acd09af170b200f60488712caf2c54ee577f5e55ffbc5bacc391c04c0810aa81aab8f034700be21c4b1bb61c4333c517a9aba73a180d95e36ee8444f39ab596b2e8ce8cdf053cabebbeb8f4261b74443334a9e5dbba12c3151202e8e4c002640cf641abcbc0b7474203cadfeda43e86b0e1dc3bec4fdd80eff6951ab5f99ca98dd761b31e0bb52b0ec01100ee17009f6a9beaf26b8fed677fcf06a5e4b8fd5f2e8ebe60f72ac69b6f2d368e59fdfd52ef39f170615c71930f58f96920141727958ffb8d332b906b99151c4260e1f00d026228256ae141b15b5ba3229b242b009ba64d5b561b24c00dcf05025ddd586ba404fb7c12cf9717839aff6abf55559f5aad251764cc06a55906eab8f41c977f07d04957b96ba8ed5c2c84bb273a853aad6dc0fb9eb2e40af6be84003a3138e1c0f9fd7daf8f370586f8f85cc18cc2e983073b036ef4d338b7c302c082ef32ff3fd8d5c65606e2f354ae99c1de13e082758dc1b6874e67afef43d35677314fe3b71f5a6483bf0fbff20fee7c3f3a2fb588e03cf4195419323d5ff79efd32c7a9f7dcd16a91142d2ec233298905d0498185844ef641470356d8766f0f8b00fef1c024be6521a6fdc3931e37f6b3b202e8bc0d5fbfa78fddb56822bb7fd2c421783c013d5ab083942030465b568e8c4ad177ae16c205972590c6aaa87bff49e3b9d5b16a2054df79e8eff20985daecdfbce90de7202d98ac382b20d7322b3884c0be28b500be1f73b44500d8efa4412a4a029cf98b854a2b279339c0d7983d679ed5fabb2a01f1f5576c5f1329cf9229a69290894af0bd74b4b558f76b28d1f86efabb52f84b2e2db2664aa6ef89238570ddaaa8671f6e793bda882b9582205ce7f7118211d025dbd5e792a5cb8da0a810409e1000f5fdd9d973b01011a153cb934f4d33841097e526a5bab875ab577fa48ffbf3374da9a70ca35d8fb9fb7eef911bc3cf4d53867a87e1e6eeb398b4fcf0be5e999b21012004ebfa240b90e6ba9c05919479709d1b07465dae89c2eec519eb4e2c804e085f292a74eaa124405b512a94a79d719e5360f139e03c49ca06ad3f06268199952dc608bb89813d6660506e9250178a63a5df38fe38a32292cea204f7c29100cd06a4ad387ca73233761fb678f0fdd6bef8676f3cc2078ea0a74e8b5c0d21804e08431809ca6f3f411380515882d27a3ec1710996364fd5ecc04df54e3f1b17ac2499ffba45ddf77d3cdcd67d16b1f98606df3ab1903599dbeab65bcf521c2bfa95c323f35f9d63f9935d0c37801203ed6065bc976597680270b95800578640a1e26ef7e847ae63e01dea7bf1ec2b04d009e12300507cfc9d1200cef9a78e9a73bea7ea6c44fcdaa4961f60281510c1dabba37391f1098db4a3c70280f3d77f1e8d6c04c71533413f782f5cc728bccc9f636f7399f2dcf7c5b5767c80db9f029eabecb6e38dfd76bff5a0f51eb961d5e1b710402783a130290800670670e08ed60660816281fc4edada8110d2a0dfee960e371138aaeb82e5c3a3df1ffcde7ac687ee1c693c235cd707aad0c5488961bbcb02c07102ba5d7d366fdfc42a9da5d84c19b12baf0ff862c943ac0501c76ddebcc54aeb2ad0ce5b42009d0c5aa09800202c9800b4c0236537949fa407bd88f79d36ed692b65475bfd7f5fb15f0b1fc67f5f820880530ed777747d1cdfc0d7e65a544e710b1c3e3eecdbaf7fcfa06ba16d0d70560445f58ce1d197f6b6a0744c5cd0c3115d0234b1b5b7b124a79ef94f37fe5577db56fbec5c389e2d9a92346027422844d5ab42619af1f4cc5018572cfbb356fe3df3466925c12d9711bc63fa06502173b5ac20f86a7bcf5e030c818485ad4567c6038c5afacb12894013d30faf05d58f9f664e11eeb857c862a8675f38d7adc4d415d9fde6c5c675870e2d4ab422f0bd5840cf35f8b4b383246cdcbd997d9eb25b8f0f6e7d31ee9188a07ee3022a0a21804e062d50ad8d56cbc605af300150e56f5d17d7e8a38e25006ec41b1cb1e7827bd5cd1dceeea8700c082c28874eaf216551d7d8fd541c1c63b21d400095f70fd4f7ca114c6b7babf31d15c37b1973acdd97420d6c12cf9f4097ab47145956841ab69ba2f2be01ced69f061531b8ac46527dc279bf3bd30ac0363636491ab033020b87d1ba2381edd1bdc82200aef507e5b748007d87eeb95cba2e4dab7fcc3ff75be6b29153c79364d2b886fabde9194d0234b8e90317e07b7606527ca488d36eb9323adfaa1b620268b58ea7ee83ab5ec0ba7ff25c1dcd99fe10f00cdd0b7b18bfe977fcbbb93d2a7a52f7d1f4dd6abd0dfa53287c54b356ff4f62017432184a8404adeead8b8d8835ed14a45b11e821880413fbf24012f07bfb236719c2445b7eef28345ce18f6e65db8260f3734e7f5f2b00ca08ec5b33c7ce6e788ec7964f31b100a835a0a0153a3e17de7effdfa321d4f1f13f7848483df7f5c34e8ece19df57dbbe1df67f47e07213300172c541b78d9b608ea948fe9f5ccbade01001ffb97aa00e94323346046e6dcc74c525dd84b5198a1507650836ec8e8607fff0c35586a04d2b6f496cf9b9e01f562cbc74b4369ba637d7129229cb42937dcb4bf63989d9cc45f18b1d693faa98b4a5c7813f4a1c3533ffa08f2b1fdfd5d9fa37957c1cb8e09a0b8083aa3fc0cf8933005cf5a610402747a2c490390031091882ca8d0c84c6f9a7029b3810253aa6b2725b50ffc2a0ccb5363cc92a8985d845c10a67f8ecf1f3e145edffd6f33d9cc1bb1f50b51f17d053a00a3ffb7977209035d7697c23a1c477e3c628a578d3a8317abb5a76bd768ffe5d764b3418e82d2f9e619d67d2d74f0b01080e8e202c4b8044975da3fa261100b44275ef3d11dc3572b0a5a8d8f70741ae7ae20254f8338fcdcb7303a0184440826d050916c089f18c4af0dc74ff61c38a82d34e8fb201948c2cc4f7806762a2e7e6de3f3cf3ebafbf656c53eb9b9a0e5824d3d2deaacfa50649a5a3304b0c4070c82c08d7ec3e89ca8feb0e90ef6ff8ef8e163244ec8e80c24d18df2b28ec8e0800291c2c15f7f6b5527cc58e60de965b8fb55a731a27c0e7e0ac067acfb4c8c965d6d32a42ee5cd8cc6fabdf696cc3fd10d6547fa60380a0fcd75c1b154d490c4070d85d8c2402e01415af7306c54ae6b2ca6b0d830ecaf2c1ef9de9be6247eb0f9faa5f80fade7f803befef731930e8f3a81194d5fd71dd86f5bd23543f3dc258df56576dec83cf53d8bdaf510a8c270b854f8901080e3b7c0a61b4f648f92d92484100a0b49fcc38df3817a7d86988a0d851f20b3d07b973005a6a4a6c055e7649b06ad547e1577c5efc2e38c2f3590a14b8f587736102904141053f39b8b53c23c88d9b56a42280172efb556805b42dbe50130955669c3928ce42f129816cbac324019762a6b51268215068ded755e9f3d5be78a3516ce57a570afb56bd6490c9b9e75f1c7cb0fc43210041e72308ad54645c80bf0d3f256c11b1f252cba080f1e3e173ce0bbccbe0f2fb5dcacdede32becd16062205c3dbf82aac948c25557df6098fee202083a055c020fcad18e94dda5c00559ba042ec5a64aee3a1e261dc1c5589cf2eb0e4448e9cbef383151d981d8f0797b2c29130b40907f485296e2844220ea12c0b6175f7a99ed5be13bd6652918febaa76760e9e8a32d42503850f6a595b284ed6a91b9010582434c1095152b8caed554c17d1680a5d89e525ec0a64d99c94ed28c9d08797f991d58203802164401e35a5c79652f63dd8297f9384012e8beaa0b30c6f537fcd97229840004822384c083aaed6b122c808e446b200d29c03aacfc38e837f24fa324082810e402410294d2b6ecf841b7e28ddf44e3f82be0c94d7ca4404bb415b01500c74b164020f81723089ff2df73ef3f42e55f7f7737abee9f12020423250d2810fc0b2148016c01f45ef9b9a5fc4020620108047948100a5c5620d7f72e10080e33020f0ef7b5050281402010080402814020100804028140201008040281402010080402814020100804028140201008040281402010080402814020100804028140201008040281402010080402814020100804028140201008040281402010080402814020100804028140201008040281402010080402814020100804028140201008040281402010080402814020100804028140201008040281402010080402814020100804028140201008040281402010080402814020100804028140201008040281402010080482a338fc7fb91bb0937a0d32240000000049454e44ae426082'::bytea); -INSERT INTO map_tiles ("event_id", "z", "x", "y", "tile_data") VALUES (9876543, 10, 563, 301, '\x89504e470d0a1a0a0000000d49484452000001000000010008060000005c72a8660000000970485973000003e8000003e801b57b526b0000039c49444154789ceddb3d6b54411406e0fd253676a28262889d7fc03a88a095a58d60a3086914b4b19274b6b1b0b34b24858a856015b01052f8016a13d0240451939533ee196637d7a492dc98e781e1667377976ce0bc7366eeddc10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e8861589a191e3f39357cfce86c39a6fdfedb807f2c8b7dedc96c0982179f5f951038757aba86410d85edad1a0e0202fe0359cce7162f0e070b6b2504de5d3b528a3e0bffdbb387b5e837df3e2fc7f5f58df2bc314b33c3e1cafc9fa30e02fa2f0a350a39daff10c7e8027239d02e09fe265e1f0152436069a67614dbdf37740cd057599d19003183e77e408e2e5dc1104110e3eef25c791c4192dd808e007a280b3967fcb6f0db0e2083a06dfb4b8137859fe7be3e7db02318f6fb73021df20a4088b63d970331f2e7ba441875075d5d401b0c5ddd817f3ef450cefa972e5fa921508afde5d51a0465a65f5d1e5b1264c1c7b9ec04ba6cbe59d201401fd5d67eb4f3dfaef7cb26dee87cd9e01bade5dba5423be3d72540b3e6ff70e3980e000e420074b5f259dc31c34708e4d581b613b87de7de58db3ff57ad312000e82f6b2dd8fad9f3b5af92f7317ea6cff71f64c2decbce4978fdbd7b501d086c97e7f566042167f8c721350881b7946dac28e1038312aecc5d55f3bba8428f628fe18f11e59fc1906fef9d0336da1b6c53aa609847c6e167a8cbce6dffeae8440dade1600d047ede65dccf6b50b9898fddb593ecf8d15fbcafc8e00d0fe43cfd5ddfcd1fdfb636bf75177d0dee053f70c9af5fe6e43fb0f3d36d9f6473b9f2d7d5e064cefaf1f1500f0bfa8b37933a397cdba85b5ce5dfcc9fd8178bc570750df13e897fcc65e1472cefa1900adae2f04b561b05b00e4cd45fbfd598109b59057e6eb066099b5273a82bc51a82b043edd3fbf67072000e000999ce5a3806fdd9cee5c065802c021941b86bb8540ed1eec01c0e132b6a908000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000083befa0d7cef58f39f253a8b0000000049454e44ae426082'::bytea); -INSERT INTO map_tiles ("event_id", "z", "x", "y", "tile_data") VALUES (9876543, 12, 2254, 1206, '\x89504e470d0a1a0a0000000d49484452000001000000010008060000005c72a8660000000970485973000003e8000003e801b57b526b00001a6949444154789ced9df9bb14d599c7fd6de6a7f913461d8d66b2284a045cd071894b9441c76d30a3689eb8c70541dce21e018332d1c4b8665c07d7cc44c751a2c1282e410171431111bd2cca222017b817b84bcf73aaeb3dfd9eb7cea9ee0bddf732e6f3799e7ababbaabaaafadef7fb9ef7bce7ad533bec000000000000000000000000000000000000000000000000000000000000b07d538930d0d70400fd44a6f8e927f9e57b7bec53697fe6da6c49c13f07e01b8213b413bd76026e59fb876bb2f57679f2d161997370af380880ffe73811fb16df38018908ece2f695f77e5f85380a1c04c0768e13e9e763fe3e10b7760093defb6db638417b51e7dbc411c87744f8b25c3a6e68b66eeca9c77a67a19d06dd0c800144b7d84e8ce78df8818f06b4231027e096cac2a94134908a16dcf70f1d7ea03fbe17bc16bf8a1cb27d7267d408180ec036226272a25bb3666d20ea89c77ec73b02bf7ee1d482605ffdf2cd6c11c7a0b7b9e36c9a766ec159c45a7eb75d8bbf9e23e8e96cc741006c0b5641223adb0db0a17eb69fdbd6db5315bfc91bc4a20859bfec9623c3ee44ee54bcd388b0ea918bfcb5b55dfaadf84ef97164bf7a0e04cb81bf7a4a15b2706a20e02d4f8f280c13da90ffe1ab7f529972f129fe3be228ecbe8103505c78f4102fde9890ada883cf2a3ad1ac9ff978c3dd0acd5fbd71c0379faaa9f71604e505e8449587f62262dda2fbfd4c04b0e17f4fa84500f9f7757230354270d00ba704d752e604e4b5bb7d655d31f76cdad0b0f037767766af415234c240ffef00b61967c85908ef043576a7829167225bdf1624fe441c6e9b0ef5ddba7f1d35da7f57447ffcf143ea3a80f91f2f08cf69af21a76dfcee0567e016711c5ffcfb88c2f7cbba160d21b90d15c978ccf0a706f384ed1e2b321194169113ab4e02ea5711f6d21bf6f5fb8b43b1a29ffa504df8eef5a9a9b5214519864c0957ae67f91d277b51ba65873fb617bed3367eb7cab6220ea5abb73b38bf47e539e45aaaebae09a3a11206fa7f0f10e400c4c83be64d0f04250e4084e144e716ed00f431964d3ed27f96efdad6df1fd30c1fa6faea657df835cf4eaaacfbf3bdfef3cc996f15ba09d29dd9f0ceb3d9e7c557edd1d0b137befb9cff3ba4f60fba47de119804a8e90609b3fe724fa50c4c145a4620b04baae17fdb65df0e0c5db7fe5eb479cb6b85fdda9257bd3874e1504cf8524a9c89413901c741071f512a8ab22c7f5fc3fd58c4638ffbd1d79f06eb92e73023203147108ca69888a90c64004d2726069b51b7c2ed5cf07a90d81327a187f49cf89d51ebd6ff80e161e190fd5ed9c880bda6d87a2b5add3508ba09fa1866d830f67738fbdc0b2ba5e44552f23e562e1d7304857a893a60fed0745246af2984ec39bd9f4ccdd6ddf5f88840e8d29aea753a82d0db659bfe2cfb388e9c7e465420b16b6d64c8d0d1bd6e45e38e456d2feb02047511f9b2e7a0aaf08f3d7cffa04cda2db7dc7a5bf53b97ec541acdac9ff9a85f8ff943d349894a1ba5edc3eb7d64fda4274f0b047cd0fe0764dbce396748690e402fc3f61d523f0250e78e8d04c484d45051908920965cfb83e03beefad73e7f4bf4d8b24e44ad9783870f2b8c92d8cf3eeff2e14b85bfad80e9434b880941b7862e8c1743d4adb7ae167cfc3f6badb6eedbdac821950bd0518288df1dab67e3daa8f01b5997dca75e4b9ff85e2c41699d5046e44e4a3f12a00aa87471945bae187b7eb41b2360fed012aca15b03d40e40b748d7df30211a1db8e5cacb877a83b7dd8098f075d81f6bfd537de442c15264ddea3f5c5ff8deba19f76f9543d00ea07bdd72bfbe67c39af06f57320428f74c6827f0fb5f9c997dee9c7662e59df38b75180ecc1f5a8236eeaa11ef58f9cc8cff6bf1fecb118716ea0332d12eace603f4d0e0513faa16ff48323025fc6074c18866f3b279810085deaecdb9faaa63f49aad19464c51779441399060dffc77cc9bf75174c850ee9df03504f9fb5f5dfce3a8e3c0fca125240d3bc7b6d07a9fd913f78d86f81f5eb54b3467100bffddab4b96b9f773eefbe7ac30285684e49693478d2e8d06ea09dcb5fcdb4a59a192c609bcabab2bb8bed83e574db9a1e604cca42c1f4f3ecc0f0f62fed074c410db2edd55450061f22f16babb563e96e9d7e3fa36c3af8f59960fd0230deefda0bd8b8e445fab1cfbcb5f9f50cb4bbcfa60e1b79409b1999143676767b6bf1dd74f252975f7eae4e38e098708a79f54796341b50809f387a62346a89376ba45b3c2d4461c13b254da4957c07e577202f596d439ea65f9eb111366f07d33d427dbd6bd7457f6fea231e32b33cfd9b9783e554ba0b79d76fa990d3b1fc9b5d82e815c0fe60f4d471ba016a0a36dec3f4487ffb4381dbeb0a752a93c75e3997587fc52fd7f9b239073c5d6e9ccbc754cd9b58f8fcf15a0f74955ded52bca912ac8950f9e573de6253b96ee9f3a7f8c438efa914f10ca88803867cc1f9a8ecdd40f1e5c8b0062c5396eddfa594f65db638295f5ba2bf0ce75bb0519fe58b8af2306eb880207a4126eb17dadc86223036542b4371509f2b7b0bfb5d128c2b2f4c6fda2d76bcf298e00d387962086a6452b9f975cbd57a14f6e0db610e2e7dd095d20f4e9d81d0b8297feaded2ae87d86edb74fe5c53b0745452ee7d64e45b8f8ac51697136d00a2fb9369cf2ecd3b1bb96e712cc30a2af065442d6fb7f71ebd17d4e6262fed0129c711d7b5ced3e7d1d0dc85d7f36f4b7e1b916a0db5ffaadb675d74ec1b6f8b61b60870d6d0e40f20c51e7d0e0b87ebdb904858e8e8ea238559fbffdd507c22ff4f6d4bd05b891ea4507fd7f682981b539a36dff2c109c4405366bedead7a52f6cc5ebd63d31b5285e7102b6c5b775019201b70ec686f949071061c92f0e28084dce111ba34f89557f6ff03e0754fbe963772e0a78fa4995950f9c9bbdedfa6a7152e0f6f85d6b9654563e787e611b328096e08c2bd6df96577f875b8940ec10a10de74f19717872bbaffccb5b4c7d1df6d8b12e80cd2994894be85abbace19c80ac5f7ae301b595ed9f0577f2652847a0af7f6b0a8f6260fed074bc3dab1b52748b7cc8a143fcfa15f7540b706222bbef9763a24e40bfbfe067437d365b4491cdb663ca65751ea1fd8d47fcfa980328e41414b6afae5bfa51070d0af6d7a3017e6a73857cfe7afa9d8163729c78f2a9d1733ae6ce7db73062912235ac29af983f341d2d246dd8c1241d4670da88bf7ef13785feb823d6a7cfc885bef8eabd82a707394763cfa5ef3fd0fdedac3b920fbbd908c0e6082ca929c2f61b3c382afc4c787f6c0fd6176afd5d44b166696d3ec53c01f8f9b85d83efd48b3434b17d307f683a625cd2d7d7c93b1171c7fc19de0887ed1b2f0672067ff9190717b2fffa386edb5bb3e604157edaa1588723c27355803295963f5fa4db5118ad3077d5e91266f77e8f4143938e427edfe22bbe53d83661d2e4d2ee827f36428998fb447e7d983f341d67584e349d9d9b82849f4ec6696c7e408b70f9dda716beaf936c223e2710273e7b0c41bfcff20feaaeba11234ff0a22a9b6a4caeb56c7c5dbf96893536fcb9f88aeffa6d9b366dcade5f3ce6d2e8b956fec799c1e74d9fbf9d1c52ecdeb03a7a0c4c1f5a8218f17147ec9fddcf5f18d69b7e5265c8b083bcb1dbccbd4dbe151c8811cef2bbfe2d7b957039733ed34e0c1c4df0ddbc1c5616f7bd03ffe9f0ec3cce01d8965fde7f327e673f25590c2bf45553c714d675ad5eec9dc8b0991b6b518f8918e41cda51b8eb5bff56b558cad1dbbda54ff3fed96bc5fca12588e1cabdfb857ebb7eaa4f8eddc72dbaff2bc37cda80758b679362dae9c4ba0ed9b18c13f077cbbd3c3a3abaf09bdbc21b90e45c7a29139c63c3dbcf248b9f92441e8c6a71eb623709c5f613307f6809da68ddb2effe71071033d254a23065d0b1755f3d71799033d8b2654bed98322b918a02dca8815b9c80e47d6a2830962ba8376458d6b7d7db563d7c41bc76e095d3b3ebdd32fbf66421d0bc7b7f1c26381b00f38796905957e491deda01e8e141dd92a7facfa9d64cafcf86f7f44cbca698c887da0ba77aa1bbf74e384f7df65cb04e2a17dd1c02a99c40ca01fcfc9a1b0a02efedde9c1c92b374b7afcab6ed33bb2358740272e54361514f6ab44147457bbb89447100d05f0ec00a2316016428c33e7ac4f141567d5bb3ddfadcd22d10a1bb45aa0ee58124aeef9df5bfcd9389528bcd6f3c705ffd4947f5efb662d50ecf3a001dfe2fbef2fb85f91505fff7bb64c7424250bfa204683a5a783a89167b5047d01550d56e36c916730431c7b0f8ca6a165da3cfa9730b312720cf1b70afb161c77a2dbf5c97bc7765c28edeeedaec3d827bc6a0c5fe262b7e8902ea25fc6c8ec23a591c00b40c6d783a81967200d6986309b2ce4fcb43dbb264d792eb23c379ae3ffdc28999f88f9cfe93a0f597505a1f5fd6e91b896243847d2195cf70e7eb5cf05af6d946006e5971efe98563bcb6fcad4afb5fc26862c97543b2edeba6df993c2f3280a6e30c2b2610fdd49a9f1e7f60e000a21571ea73a3dd005709684706e47a822e8824001532fc2711806ed1172dfa2cb8ce46c4df979afdd83e31f1bb25b6af75a2325a32e9e62995c76eb9227a0e4c1f5a82377e13e6c7d6c9fa54142046bba9ed9dcae6a51f545bc78533b339fa1cfaf977654c9bf662d005c890597373a4ef7fd66927f8f7eed901e248c401c8b6b2ba0427c0a79efaef62adbe792e828e76f4fba5370d8f8a3f26e4adcd9160fed0129c71496bbff6cefdd20fb330b5004e549b5e9b1888c13e736ff1e5bb0746bc79c97b05c3b6492f1d05e84860e3072f66dbe69ebf5320e2ef0d1a5a797cd1fff8bc803e9e38003bc39025256c5b445426e664eb9f183eb5bfd79d571e41ce3d00d06f64165747f45ef8b9313b43cd8c775cb5d22e369e2dc2e9e8ae96c8c6286b0d63853d7e6850e1938211ba7a6bcf09d0390e7f8cc883412db6cb133b575fc27fb7cee50c56aa1a8246ba4d48025a8233ae41838644c3fe1513f6883a02dfd29b10d96d73826c1bb74bd4f0639f7bbb36050298fe9b732b6fdcf2ddb0953765c741fd819e772f6fb1e5f311d3cff022d6422d64dd75b15144e43ac9d897fe7fa3c486feb24953c6edc20800b496462380c2c41791e1bf9471bbefb99b615255776ee95abdc47f8e9517eb21ca40b4b958dce29c8f84fcb25e70ef45947e7d6f6f762cefb0225140a1f537fba4c4af8b81acc8cb1e50621d4dcfc6eaa3c6d001341d6f7509c1bb9b83fc6725363d0c979a3d57a6fdd2e8245eb2bebeab239aac739f871f188e0c4834b065e5a2ec7c7f37655af47a44fc6eb1dbcfbfe0924adba5dfaaf474acabae500297ba839edc51483f5d12a1f5c41f1403451c81cd57d8bf4bf01b00fadb0104dd0213ee8b01c79264cf4f19e3c5142d7251fb7eb0667e38aa1079be9eb4fe363ad075fe8efb3e7e3468fdb5f063a1b9ee4ac4ca9ae5d812594812b3a773bd2ff469c40124e705c87febfa2d1bb2f56d97ed5e5976f3613800e81f9ca539c3de23cf012417d5471621caf09a3880426bfffac305434e8d89a79c8420330589e02fbab0f6d92d722fbe63e4cb67070f254d8a3222742bcc95771c53bbdf20dfa711d1a7cea79d4d2c52492503d103b4cc0138dc705d6adc5f847fc0fec3bd3024f497572762f7741c49e895095eb3fceed30a02b051868c3088f8bf7ee1b6e86db6eebded9234224e39876343572d4128b504e2e0b646f87af9e4a2069e1a248ee7feb3b357f7f7c40140cb10a3d337d438bebf67b52c3530cc85532bbdf31facacf8dd4fbd1328ab9eebdd5c9d3c4353e82ec486def2754f3ff3acffec72073d3db5c93444f03a4fa0bb11e2041a1567308f9fba4e7d8ddbea00f4f1dbdbd767afae584a5871777592941848005a8216b72f7a31956f655977799f0aa9eb25bc62dbf418bd7b7fdbed7784dbf2ebd077f7f973e57dedc9efdf9dfd9646c539f8c119d59188af3e0fae6bdefaee86937d8d760552515159b484f9434b10a1ea84dfaa472e0e6bf0cd387b6098b91390ed12ba9619f7f27b4667af4b6f3ab0a16abcd44d44da1904f305ee577d8c595ffbeb724eb9c1489fbb590e202af2fc6faabb4feef5eb3fd51c1fe60f2d4184255976097d0b65b3227ce500ec745fc961bd3e901db3c1c77909d681394e1e35da5f475f057accacead37b56fffee7d59f7cddd627fe62eb6c42d0fe5df5eff225d600cd461b5aec3ef9a08fad1c806b1ded505bac0bb0e2eeda8332448cab1e8bcf98abcfb16ecbfacaa12fa61fb2a1230179bdf0a27195b12f8cf25d809e9e1ebf5f5f9d80fc2e7dbe664500727cfb9b7555e417938ff40e1507002d436c506efdd5f7ffcb7c00c2acd7a767c6baeec9b34bf3005aac6b9fbfb5d22877dc549c5b2fe500b4308551ef7614a201dd75d8da7ebc8c4034d301644ea07365768d5f5cf78fd1dfe60a93f46f4406d07444fcbea5cf136bf2591e0e52109c7b46bd29b1d55180bdd1a66ca420565abbf98bf95be50c82614c73deadc909b46af1f32cc8df48e759f2f7f2b777af983eb4041189b4fc6274b24e0415f459ebdc34630b59e6cc995bc80d7cb86641e02052e24e7dd6e7883a04e30062dbb7076710442af91c873aa929ff0bcc1f5a82d3c2c81f1e1284fc31e1ebfebfcebcbb565f32d57ff3d0fbc921ae79f33e0c3e5f70e1d8c250e3e665f30af70f5867a2dfdb3e7ac101244a6ffdbebd3d95bdaefa75a940fbc349b87374cdcf6746b6232ef96fc0fca12538e39a307172a1565dde0fda6b68b414381601e88a392db891c79939f1b5816b5126a281642baef60b9c4e6f8fff0d364ab0ce69c3c3873624d0568adfbd4ebcfcacca9659b755964f39b4e8a87000d02a4428babf59e84b4b2410b91148846fa7de8a19b188bf9ea85377163af11e79d4c8e2f4e4ea3a026cd7253fc68c575f0f5ad8eda62ba0a634976bf5f30200b402313427fc777fb65398f41391e5821b7decf042fdbdd4cadb61c158218dee1e0c1e5cabde5b7ae3fec17e964202514daf95dd3d377eb7c277e51162a9a9caf5ec456fcf7d77e0c59f706af2e051ac1f9a8e15984efe05f5009149406c5f5f5a7e5d1568a30639c6ad536e6f7862d040f842038fdb160710e430d475e88abf016ffd2bd57906cac0fca1e968313b64684a0412ab05b8f2854905f1e9db821de77cd8194401f2c00efd3d39a67524ba70278644150d3d5557755f52dd8a817400b161cbd4a807e60f4d47449289518d3b57da3f0d1c806d3d6359797b6b7066b4ea915d3ac9a71d40cce82d6efbe45b6fab6c0d5ef83dddc9630f7404109b52cd5d57c7477faefe068056e08ccb26ffa4351a3b6668c101e85b7f63c940b7fda2398f7ae16907f0d5ead5c150a2ed02948ee9e7eb7b3654e7c51362c9327bbcb2d07ad9a443063c01287fb7b26bc5faa1a50e4087a07be6b302058f024b3cccd20a4e1281b642500f0fcab69403881db7f0de3c1c44687feda1603fddd7b7ecf1fa8660df5488de6a07e0fe1ecb7f3baa7c5404a0158898fc6b6ab84f85fff5fade3a9c15b1cb77644aad7a0e40470387fde9b4628b5ef2908d1422b0ceee4d411e414fc239501140b4264281f5434b1021eaf0dfced0230e61c5fde71427ea4c7403ec0d42b662cfe600526881afb86774a19bb07163f5d65d3b9fa00cf1c9e2b66d5e165622c6d85e1c8005f3879620c6e76f044accfa1babd24b450229f18b283b163e967d16075056f463e95af559dd7d62d7d6968fa5a76ebfddd6c93eb7b50b50eff760fed074c4b8823b0195386261a94e56e9d0f9571f3d50f88e15f6a2b13b1586fbcaba138d3ec25b67cbebdd48a4cfadcb91d7bff9f880398054b252ff76cc1f9a4ea1f24c67e62305339a396fcf2d7dd28d9d224cd7e43bda9fbeb858dd17317eb7cd3a023b49465ff200f5461a66cc786dc01c80758e380068295553eb0d8cce470279372059906372016e2af058f85f287155f7bf6b01eb68414724b2dc3c319ca9c88bc338967a958205b199dafb8188002ccb7ef9c3e037220368094e2413ce3bb99690cb23007100fa96df89936e2918aa84f03366bcee05affbb5de0188e07247f0d1471ffb563c3533702cfcb74e41472681b04b6e14b24ff895eb18c891805884850380962363fd76f69f4c74bdbdc12dbfb6fcd6760db441ffed8bd5d63ccb1788185faacdd3179ba9474a85a5d0480b40d729e8f5f25e1ed3e531e2b7958b72ce8ef9af543f2c9c5a99f2c1bd852707f7973390eb8975011c48015a8217a112966eb1275f785875fb2ba757bed8b8c23b0237f1a688d73e732f48fc45c6ea7d0bad85998b7bcd3313a261be0dd10bdd0023f654a9b26d69ed8d4c77bd7c5dc199f547eb1febc2e8df8cf943bf618dd03e14548f04c41ebab9f75dcfd5c46f96f533abc37ff62e431bb2db877e4a3250a213eb0402c7a29c8b8e30dcec43f2f9ab27af2c884d4f62d29f0e2026fe2d5f7e5c79effd0f880060fbc31aeb7e738a062d628ccd012002f739001525e827fcda565ee705dcfad913be1d1f1e8cf4fd1d4bae195c731486b7de9a9dbdfed7e7d3fa3d07a0bb3df2db975c3f34f82d03fd3f076898a820cda3c183a70de58e2215fac7dec742ffce8eaffc3e8ddc04e4e8cd4740c441f5a7f065714f50d2b74ac7ae19f3836f0cd6b875b9aee420dceb638f54453e7264edf1df2254fd1d69d58363a863c7d0adad7e1d080720d75f56cb30d0ff33807e2326567100f7de35acf2e029bb65ebf58c457a5970d9ce41abbef8eabdfcb14468b1598c0642fce20062ceaaedf25ae932e6079070101231e8bc81a37753f556df4054e3762dac933eb813637ff6ffade0bbd7ad883a03fef1007da0a0f0440b3b7ffe02dffacba2f300ad7606297422b4b2e8091c004033a9377f417f86fd36121107401110c00062c5d954f1e7437b6577419e7966752a36078600f00d7210f5462866bf39810800e09bea201a99af80db8001bec124637fe6010000ed10f86b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ecd012fe0fee1c808a851705bc0000000049454e44ae426082'::bytea); -INSERT INTO map_tiles ("event_id", "z", "x", "y", "tile_data") VALUES (9876543, 13, 4509, 2412, '\x89504e470d0a1a0a0000000d49484452000001000000010008060000005c72a8660000000970485973000003e8000003e801b57b526b00001b5549444154789ced9df99b15c5b9c7fde5fe1bd7ac9a980be202c875414554dc89328a46a2c468ae794c8c40dc50d044e32e4662508378a30e5e15ae7255441c8d310a2eb8a2111802c3308c33c06c30fb9cd3f7a93ee7ed79abbaaabafa2c53cc39dfcff3d473faf4e9ed74f7f7adb7aadeaa3ae8200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a8540c1f7f500003c1a001746f2fa0000654408fa3fc68e0f85bdfd37ff5e883d80810060b49224666114c840940adfff19009067d703674939bfc90bd0198153d6ce927fafab0953d7aa05612ad470d0b50100ca0c895e153e5f479f3a417fb0e73379457dadd6181463106020002813aab85a975d65159f5a5f30f9f58be31b6d5d2e6dfff3192784c6a076c16ca3316869690d8d07198e300541b82d0c040065c294fbabcb2474f1a91a818196fa98a0f9777e3c9377108a9f276608c2df0ddf9bee3d1d06028042d9f7d1ca98ab4fcb5cc46a4eaf6b393089de84d89e0c42aca890d21090c7109d375f14993869320c0400491e80ce1320911e7bd45192b06d750249b4bffe47ab4150bd039321c8be3133dc3e66044412dbe711bf3fbffc182991e1d1165f1c30de4c00461bfcc52e240e4008a9bbbb3be609dc79de61d131938eab3324aa671079055b9e92c4bee3d91b8d8680d68b4fd5081027adfd49f8f94ef3fb51dadbd7ee74dd267c3f53009c7179a175450497ed75fb0831ee5db9507bacbb3e7f44bb7d9257c08b0f3a4340eb49fce21c54b9c82b19c57a320249ff35e977f727008047c48b7cd09aae505085bcf4bcaec0543470351cc200884488e388ef640474f5055cf02e86807b05a6160661005cfeb7e9379fcf138054846f6cbeccbcfbe95f258a75f733d746e2e488efb7fde5e4584b01b1e7b91bb4c753cfa71a012e7cf57bace2b0ae26c8be915bffd5b279d27a535c02375ab49c1de835de0753a52727dd1300c02341fb3fa54a339d284deb62bfb1e37041a9dbd167ebb25f0c0b89d5e4470640addc23f11b5a10f871d49c5e1806a9f520088285d7e6ceaf121dbb407c3e4f005241a2e5e2b4b50a9804439f54b32e969bee999ab86fec1c2ca7b7259b2130e5f892cb9f3f4f6ffdbad8ff4dd3b489220018d570d14784b5edcf04aefc65e993b260f63726ee630b3e7aeae9e58906801b811096eb27b9fed75c7c8ee4715c74f165b1eb6b7ef882a0e9fe33c2e5c1c1c1d8ef7b57de6a3490be9f2900cec46aec0dc501f1d9f1e61237af207f8c48a0f5b541d33da7a6f62ac26d1d8d8010a99adb736f60f7bd47c68a05aaab5f4cd31fdfcffdee03e019f5e58d045c5f1bb43e72b6240c9d8bdcf9d663312190b0b80188922272755f6d91c02171d1df7ed50c734724536b81c6ddb715056c86c2f73305c019e35b9cf0b2abeb796d3a2f9fabbf77d42dd19e67b06da7f6781326e63a12b9a4a993270d97f395ca408a1fe05e856a205483b5fdba835357880adcef3e009e31bec42236c0f2d2dbbe777474869fdb12028874824fe30570317363f37fb30f09bf77ac7d3808b2d948dc42e86358d182bc15292e207f2c2a8264f6b74997c2cfd3bf23d7151aee3f18b570d75c159e6a0438a2ad9cef931de80bca8a41fcdcf3685c3841ba26b18e87f8f25c3faaefb07903f5b54176d353b14bb1dd17dfcf138054a8e5721ba6aec3692ace6c4d8cb4ae61def7a2e54c2f135b524b409eece69c687b37bd133304dc1b102e3e370249de800be9ee3e009e51c5971653c08ca97e40d07467be8b2ef33efa1bbf08ba3f5f2d6d1f3bc69b336503600a3b36543892119871f92c2934988c007915a6ba815d8bce49bc1fbe9f2700a9b009d7845a3baecbd5d5e5ceba3feb45edd00741d078cb1141ff9a5ced7e142c94a7e9ae29d66bef6ffa325ae6450271edcb17fe4cdbc188571e726f80ff4fb5cf8020dddd07c033fce51d68de6c156152659ecd0070babf5813d523f01c76a8a339f97ceac01f0ed7ab5e8b5a24e8593d23e60dd0b9789120f3c6709143aa5b60f87e9e00a4c2241eca5563a29af39d68b9f5bfaf8e092ec918b88a37765e45b8b6ed744583f6d5f7c7ce2b1d4fa920bc72fdcdb98dea6a82891326c5220c63158c79d2dd7d003c6313dd605b53a2d054a127c5d327a18a74a075ab5eb096fd789164ef8af956cf858e49c78f2a009522c1f667a787eb7f79d1d95284210c0018d52409d0f6ddd48eaf5268cf3a3537a7f1016c1e80eedada56fddeba6decfad40ac2fadaa0f9773f8a1509c8082cbde8d07037dfcf1280d418c593af451fea6c71125b522e4b74d4b1befeaac16035f77c3d35eb9101b0d1f2f870c71e9728beddcbe76aaf532cb7ae981f0abd61ee778c15847cac82f4771f00cf1895e4101f9094ebf37e01aa9168f8ed21b1f3f1df456f3b3594f8e6f90bc36561042e9af953eb3599be0b06bed962fc4f6a0561ed6da7c72a087951817733f6fd2c01488d4904aa9075b9fcfaf73f8895fbb9dbce0d40a186465797100d13a66cc3cfcf472052afbb7dcd22697dc3f5876aef812d8290c70cd0b5a4bffb007886bff4bbeed34fb2a1cbc1b92075828d0dd8a109ced1ae538e45fd0974e79b3bef26e3beba637143d1b6ea8ec47d4c1184b1a1caf3f87e9600a4c6453c7b9ebb5112922a2cb5f79f584723ebea3c006e24e8373e0e9f5c2f303ccd982ab89f4d191b7ef66eb5570a76bcf9a8f1b7a426c96c366baf20fcf8f6e8a7f4771f00cfd0cbbb63fe38ab106aa69f65ecbfaf0a970cc069d3ced517015a3fd0eec33f295828ea9a2bd6b76d8c4501eedeb3c739ae80cf7ba8161b92ea0ed46291e40de4f1fd2c01484d9268ba3f5b2dcffaab11082df70ef585cba2ed9edc6681e8821b13936308b0544460e2cf74b7c73675edb4e31ab1a83b3ef18f7fac8bbc0122fddd07c0332e62e1aebd209b193446ddc5a6dbaaab0936fd2adfebaebe36685ffd40b0fdba6f05c1b615ce46e0bd3f9f2d35b7a9d7632a6214435f9fb97bf3c0c040b48c0a4030aa5105636ada53dde4a8067ddef7a2755defd5c6b7a52ebb960abfc65b8f8c9d73ff8617a57549ad093cf73719809e2feb62ebf8b6bb9ff94d90c4860d9f44e7e3f87e8e001484ee254faabca365eacca36b8613ec79fe26a93930caf1852168cc152d9aee9e12f62f300513a5ed47600b51b6ed4bbf89d1805d839a04149558d8dd07c0333ae19ac42489fbb91b9de2f2b5b0414255a1fefdcae1ce4612ca5c00df2cb9c47e0e515419ec0fca81eefff97e8e001484eb0bce51bbc4f2fd68dffd1fbf1874ac5d2c642809374da59b04eb8f2f9119b21aaff6d71e341ed296d3bbace7df0bbbfb007846d78c27506bfc93cadfbc624e15b95a81972430fa6cbc6da25497b0fabe5f6acfb1e785f9d6e30876feeed8d8bac13d0dd27e3d9bfe2e1d8352f39f2e8cedab7ef7fd1c0128085550e273ecb809568152e51f05fb90c877de717cb0f7859be36221f77da83778f0fc1f9aeb17528c4fc83bfd707486c8a56ea1d8e24461771f00cf901b4fc35ff31cda34ea0d21d677bebd546a93d70aabae261877c48458100fa16b25e0b9b1a905c056b1e712ea1bbbe67cd45fe75b8f5a2b107586c5f77304a0205421d2cbcec56f3200359ff504bb1e38cbead2372e383ae8e9e919f60294b6fcc4ce42ecdad290d424e87a0cdabfe9aedcd4e77d7d7a4fa0b0bb0f8067e80556052fc6bea7a41356f8d2afe99244c2457ac9a5b3838d1bbf94caf0a6d17c939ae84cc37fabd743df5d8d856edf7deb737d0f1ae67d3fd8b5e85ca7e3f87e8600140ce5c05cfc2631eabc01f21604dd9fbea2dd2fcaf9595ffa68bde638824c4f87bc6f02ba6b16a301a7a9e9d71984c1f6a6c4ed0abffb00788662fcdb06b25a57bf7fe7f090da822f97ce0a5c50c37525f1d4d504436ff2e364b5db8beb9933ebc4d8716ddd90f9762e813fd177cd3c806dafdce3f49f7c3f43000ac732cd95a0e186c3b4cd7aa6e83f5353d9378fcc34f7175004f6f1ee2f226fe39429a7840663e3c6afacd749fbf3eb79ef17b908c334647af7056929e2ee03e017fe22af58f952f8598ae633f518a287605b5bbc079fc025c640cd7d7539be7aeeb1e32646dfd306fd10cd0f4d4fbc0f9e1f210085637aa90b118c6e7fdea446eb69704fde3f8004dcf9b7c7a5ed5b5a5a83246c118662b9e1861fa6fa0f3416415fc3a789db0b8ab8fd00f825515d61855ca731e72d246c56208dee5b5f1b8a4e57c6a6fef6492d05badf4ccd97bc07a3ba6f628b04ebc740e01d06a316dd3b2e8478e9bb73b562a0dc5a8888725e5568d9fe6e6d5f7f1e54230c805aff70ef7d0fc66afcf9609cbcb9d1a51991b76a980279928a072e5e91ef670840c1d85e7a97f236094c74ebd56ddbb7ed2369845e3e4c3855064a397f5d4dd0fae4d552d99f37054e3b737a5ca8629a6f0dd4bc99368888e8ddf25eb0f3f7c7c5fe33ff9f85df79000e00b46f7e36136fea5ad31564fbf64bad004d779fe22c269d3770fa19e7c91bd55d98137a7f7b345df739679e1e1c738c2c425b135fa66f5fd0d5b52fd8d2f1af9c07b06b9dd34c4249d71b235f04f0fdfc00280ad3fb2d845488485cea086a2eba343224da26416104f2e5ecee376e89965ddaff75ee7f315e808851b051dcdd07c033a188c4187d79311d79f4b1c6367e011752cbb2ab8c82a73061753daf1be039faf88f7aa2149d8357b8f169bb53e6d8491e806bc526bf66c2f7f303a028acca0882e0b2d9b9a1b42531e787f00ad174e1ed58fbb0b3e0b8f06346401d3928454830897e6b678334fbaf54f1971932f624d4c51590a1e41477f701f08c2a481b520c3fd513b8cc213837dfeca60c03a6e6fc91f88f3a2e5a3679003697deb537a36d7fddb20edfcf0f80a2b0bde8b68840ddf8fea663c47e67464397f36b3d817ca5201981575ecd4f1c62b8662e7c7e5dba8e48ea6fbae345d7ad18bce2ee3e009e515ff8c13d3ba4979fc7f093489284aeaeefddfc6e60c25404881901660084b0a97fbe2a4a1ad8c4d6ab3126f4142311d17f227c3f3f008a8237eb099aee9c6c7df9793bfef639df0adbca2571ccf9762a01b918003202dc0098c45bbbfc39ebf9047b57dc22afa7263d1698a42b06b43c7eb9749ce2ee3c000700fc254fd3cc46a895652d4baf90beabfb5ff3eb39d17a531d80ae28f0d4a2797a03a0d0f5ce32a7c83e53604fc3dcef6a0d8bce20f87e7600148d2a92d5afad0d92b870e6acc81368f8eda1da32b6493451a8708adc3fac0c14e4cbffa6105f1deaef333eed89fdaebd666600a29603defa0103002a012e864caf7d6c0035d7cc6432c1d3cf3c9b283c5ecc28c4fd570d80e93cfcbbab61685e1c3fe651e373918782eecf5f331ec3f7b303a068b8a80b8f96cbb1e3c6c36202a3e3aa826c7ff5be54e5ff1036a66052fb3fad538d8eda01898eddf6c9706f44dd71759181c5df7d003ce31a0390b6a69ca0bef53c67def98793ade57f532b00253a164deca1fb0f279c785aaa21c274f46ddb107eae98f57dedefbe9f1d0045b379737da2101aae3fc438bbefc449b931fb16ff698976c65f15ca9153b9ff1b16c60c002561044cc2ee5a275fefc53fb95cf204d4de88747deab5ea7e0b8f01c06827b0a1c6e26b6ac65dcbdaa2b9b0a0f2ffd11382ec50ff701010f30074e71e6adf257d4f2ad6acdffd89769b81e64d4112be9f1d004543225107fa0c61c217037848a3f830366ddaa25dcffbf9efb8698cb4de39f7e7657ea50ec0943b77bef5d8f0727e88319db1202fc0650461e4fea022a197bcfdd57be5b75ec9f56d06c02430beaef5afb9893dd396fd25a39417ffce3b4e305e474cb873beadbdb61d37fec8697c016eb838be9f1b0025815e68d50b50dd62217e1e5fbfebfe33b4a269fac349d1b2ae85218d0138f2e197e459811c7a03da3c03be2c060a35fd57c1b684e24d69ee3e009e51035e74d008bebade75b6e8bae122c0f0905d692affb445008668c31739b9edda55f75dcc5548ebba37ae95fa3a64fb7bacf51a0802021587a9765f3500675c31236600d47e042ec624b501e0280660b07d57ea48405ad7f2c4cfa3efdc7035fff1fcf0d3362762f83b009580ae96df54734ee2a7ba00c90028c376999ad6d21a00c908a8862a45cb84ad794fc78bdbe3dd8de101808a838bffebaf374b2f7cc38e46ad011088da73b5b520de9a30ecfaa729fb6ba300eb6b83818f1e4a6cd64baacd570d80ce30e8c298557c3f37004a028976e6c597598b025407a0252ffeffbafad7da624221c227f18f39eed49c17505f1bbcfcc4ade1baadd70e8bd225ba4fb8f57cbba67b4f8b893bcdf1600040c5400256dde9eeee5c8f3dcaed297191501b3adf4ed49eab46a05003a01a03b5be412c8793792a93902489f89a53c7688b01b6fdde7fff43881f541eb1a01f06895b3500ba5c5324f1fb98c36577b914e2978a03d42cc8ae951b06d36cc73ab1ab1e0dfd9ffea6afccdd84e1fe834ac2d616ced7fdcf732ba275141444cd83279c786a2e37eedb1f134b29c54f4501baaea17d7b9c3af9a8e5fd50fcbbd669b7e3c77a6f9d3ca6209dd7f73303a064a4e902ac7a02c200e826f62865ae1f3300ac2f4012d17f1be892a60f53737eb5ebb2ba0e4d80a06289a946a90fb8f8925c0fba1f5f305312bf89728a9f12c503d8dcf47f7bdd5014b8ee606ba08f69a8308eef670640c9e0b91e2d4713720641f0757b7de466ab06a0d0a6bd62139d9bbbe646511b0629d535059a8a43f00040c5422fb9a96f7ce82ee73d824c3613740ff644b5ff3ec41fa6f59d46177de09b5ccf446d30906e1661313b906100505402828a479b3d3277386acecb770926bc895ff102d49688342c5bf694f1bf0b868632512f46b402808a447df1751562ba6642df064024d5753ff3ecf38386eb7fa06df2cb0ef6c704ce97fbfe35dcce4fcbdcc010be9f17002545cdfd78bf7f5d71c0bbfbaf1880a4813c9bee3e25ecfa4b5053a5093282c74f9eaafdbdb4771f00cfe8c4fff8a6e53103c09bce464ae063a64eb7feaecba1f975a72d12e88e0103002a1a55fc6a90cf5b3b72f3fa515dc048e6fe2ee74a237297d05fd3f13adf7e22fcf4fdbc002829f482f3f87d32003f3e7f66541148c2f0edf6ab49cdad75026eb8e107e1e7d0beddd27a1149a88abff1f6ff8c4d94c28f5fdabb0f8067e8c5e739bf10bc88a9a717ff4036006a0c80d923c86a9bfb5c4289610040c5225efefd1fad94caf899ecf0ec378277b7d5496de307ba1720b8e4d2d992d0a3168ecc50b0f785f93183e1da8ce8fb79015052f8d4d8ba9e74944396ba7b6fa90d80ae45a0f1962382209b19167abedbb0b1a95343d7aa05618201001509b9d1a66eb4aa2138103d80a3577d6515742c67cf1b3d5d28b1baad107fd4d518804a8344441e8048e33f1c1e0c8410b97fd8d7bfbed6bbe075293604399b44241ab3d012e7bf6f7daee95390d9df16353122f707154d926b4de2a770e003d103e0d79b5d7bc1707c405d4dd0f5f205d6894a554f80727d6e28c808f87e5600941c9ba08437b07af7a094638e648e5e9011c8e7fabcc8620a0cd2b9fb94b807802200a8589204151609580eeb3b97b7190c21d6bed766c839ba30086c8c031276ef577f95ba3ed3fe94268e9f14ab58f4fdac002839be855baac4736e3e9578083300541f1079069a199055630003002a16dfc22d570a855e5713cd5fd0bae45c49e4fffbd8cd4166f333fa29d0eb6b61004075e05ba8e54a52845fbe18d0f1ec1546c14b293ff2f0bc2b6749d391f97e5600941cdf422d77528b015193603eed5c302efc3ceed81386cbfb2278285f84c048c0a0a2f12dd0113102f5b541cb078f8762ce6e31e4fa62b2d1bdb9a9d022430003002a1d17017dfcc967de455c4c1282fef0a57bc2cfa1cd4fcbde8082a93f80efe7044059a80603405e40ac0890190cbf4f9978642c660006005405b65cd3b768cb6d004c139e76ac5d8ca1c04175e05b98e54a949bd372d41ac022051b178c0fc6b2b90c6ddd817d3f2700ca826fa18e48ee9f17bed42c98e7a429d38ca2870100158f6f718e842720e5fef99a7d5ed6dfb5e83c540282eaa42a0c000f0d664d7bbae1c4d00a00aa0adf021d89a4137f52999f7704f2fd8c00281bd566005cc15460a02af02dce72373f466ebe92fb87ebd954e17c84e0ad5bb7a1021054072321c2d1316c38a2004115e25ba0e54c8498eba079f1f0402191d8b359a77060dfcf0880b2e15ba4e54c34d2319fd65c47f3a2f38c138dc200808aa6d20d804834cfa1ebe41f3000a06af02dd27225c2367bb0ee779d81f0fd8c00281b959cfb0bf6ae5c68ac08b4b9fd103fa80a2ad90088a48ef9cf97f96f28ff83aac4b750cbd5fc1809dd21f8c7346f00dc7f50f19453843e53f7503616f6eb52018826405055f8166ab99270ff7565ffbeed1b9c0383e001808ac7b750cb95048b1f7934e8ad5f976a3a7018005055f8166ab98dc0d87113935d7f4377617800a0e2a96803a08899b3efc315d208416f3f3436787ef931518201005541a51b00155d65a0f8cec54f06c0f7b301a0ecf81669b9929af3eb3c011e1044c2e73101e5bffb0078e640126c49737f45e8630e9f60ac0b200320ae011e00a82aca29429f4917e66beafa0b0300aa964a3500b6327f920740f87e3600941ddf422da707e00a0c00a85a7c0b150600008ff8166ab952363fdc57d3dd53a39c7ec7cd87c3030080e35ba8e548d494670a02a2e2816819301501a49b0440a5e25bace548495d7c7934200c00a86a0e941cbb94c74bd3edb7bf7f20f200ba562d08133c0050359068c64c9d5e310640cdf95d9b01610040d5512e11fa3426ea38002ede003a0181aac4b7604b9d04b129c0983168597a85d50810be9f0b0023826fc196c3039838e9c450d0dbae3bd8da098896311620a85a2ad100a89380f049406d602010507554a201387ef2a905cd0004f71f541dbe055beac473f2fd9fbe1ccbd9b9eb6fea2de8fb99003062f816ecd8f37e5ad2e3d966fbd971cb1146975fecd730f7bb3000a0baa8c420a05757afd18adc44d867e0ae93510400d5473944e82bd904ae5b36e1fb99003062f8166d29d211773e19895b4c056e3304d98178ac000c00a85a7c8bb7144937f1876b2b00620040555349e27715fde5b3af82010040e05bc0c52495e6e66facc257c7fee74381d31c02782b40555129e2b7e5fe627a309b011069da99d3610040f5e15bc8852412fb3bcdef47223ff1e469460340dbabb93ee60200558f10862f2117d2fcc8dd756100444a13f6ab137e540c401100542349a299f659ef0195fbab824f3be5b7ae0840f87e16001c702409eaa1c6811133003dfffc5b4123fec000005046ac02eccd94cc00940275225078000094992451a63100525f7f25775ff2e852e97befa67762db3df2b05c11080300c028311069fbf99bea07d00200c028a21091dbb6d35502fafe8f00800229a61e000600800ac7c528f8be4600802760000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e1a15fc3fd3c5bcf15fd6d17f0000000049454e44ae426082'::bytea); -- ─── tracks ───────────────────────────────────── diff --git a/e2e/course-editor.spec.ts b/e2e/course-editor.spec.ts index 3815817..db217be 100644 --- a/e2e/course-editor.spec.ts +++ b/e2e/course-editor.spec.ts @@ -654,7 +654,15 @@ test.describe("Course editor", () => { await expect(page.getByTestId("desc-opt-11.1N")).toHaveAttribute("aria-pressed", "true"); await dimInput.fill("1,5"); await expect(page.getByTestId("desc-preview")).toContainText("1.5m"); - await page.getByTestId("desc-save").click(); + // Save closes the dialog immediately and fires control.update in the + // background — wait for the round-trip before reload, otherwise a + // heavy map (stacked ink tiles) can abort the in-flight mutation. + const waitForDescriptionSave = () => + page.waitForResponse( + (res) => res.ok() && res.url().includes("control.update"), + { timeout: 15000 }, + ); + await Promise.all([waitForDescriptionSave(), page.getByTestId("desc-save").click()]); await expect(dialog).not.toBeVisible(); // ── Persists across reload. @@ -670,7 +678,7 @@ test.describe("Course editor", () => { await expect(dialog).toBeVisible(); await page.getByTestId("desc-clear").click(); await expect(boulder).toHaveAttribute("aria-pressed", "false"); - await page.getByTestId("desc-save").click(); + await Promise.all([waitForDescriptionSave(), page.getByTestId("desc-save").click()]); await expect(dialog).not.toBeVisible(); await expectSavedState(false, ""); await page.getByTestId("desc-cancel").click(); @@ -980,7 +988,7 @@ test.describe("Course editor", () => { .toBe(false); }); - test("renders automatic circle slits and leg gaps over black map features", async ({ page }) => { + test("renders automatic circle slits and leg gaps over compact rocks", async ({ page }) => { await selectCompetition(page); await ensureCoursesAndMap(page); await openEditor(page); @@ -1026,5 +1034,69 @@ test.describe("Course editor", () => { await expect( page.locator('[data-testid="map-viewer"] line[data-leg-gapped="true"]').first(), ).toBeAttached({ timeout: 20000 }); + + const cutCount = await page.getByTestId("control-circle-cut").count(); + const gapCount = await page + .locator('[data-testid="map-viewer"] line[data-leg-gapped="true"]') + .count(); + expect(cutCount).toBeGreaterThan(0); + expect(gapCount).toBeGreaterThan(0); + + // Control numbers always carry a narrow white knockout for contrast. + const label = page.getByTestId("control-label").first(); + await expect(label).toBeVisible(); + await expect(label).toHaveAttribute("stroke", "#fff"); + await expect(label).toHaveAttribute("paint-order", "stroke fill"); + + // Event-level toggle strips automatic cuts from editor geometry. + const toggle = page.getByTestId("editor-toggle-cuts"); + await expect(toggle).toHaveAttribute("aria-pressed", "true"); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-pressed", "false", { timeout: 15000 }); + await expect(page.getByTestId("control-circle-cut")).toHaveCount(0, { + timeout: 20000, + }); + await expect( + page.locator('[data-testid="map-viewer"] line[data-leg-gapped="true"]'), + ).toHaveCount(0); + + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-pressed", "true", { timeout: 15000 }); + await expect(page.getByTestId("control-circle-cut")).toHaveCount(cutCount, { + timeout: 20000, + }); + await expect( + page.locator('[data-testid="map-viewer"] line[data-leg-gapped="true"]'), + ).toHaveCount(gapCount); + }); + + test("colour profile change busts tile version and keeps ink layer", async ({ + page, + }) => { + await selectCompetition(page); + await ensureCoursesAndMap(page); + await openEditor(page); + + const profile = page.getByTestId("map-color-profile"); + await expect(profile).toBeVisible({ timeout: 20000 }); + await expect(profile).toHaveValue("auto"); + + const inkTiles = page.locator( + '[data-testid="map-tiles-ink"] img[data-tile-url*="/api/map-tile/"]', + ); + await expect(inkTiles.first()).toBeAttached({ timeout: 30000 }); + const beforeUrl = await inkTiles.first().getAttribute("data-tile-url"); + expect(beforeUrl).toMatch(/[?&]v=/); + + await profile.selectOption("issprom"); + await expect + .poll(async () => { + const url = await inkTiles.first().getAttribute("data-tile-url"); + return url && url !== beforeUrl ? url : null; + }, { timeout: 30000 }) + .not.toBeNull(); + await expect(profile).toHaveValue("issprom"); + await expect(inkTiles.first()).toBeAttached(); + await expect(page.getByTestId("map-tiles-ink")).toBeVisible(); }); }); diff --git a/e2e/course-maps.spec.ts b/e2e/course-maps.spec.ts index 3dc4ac4..d4f52ae 100644 --- a/e2e/course-maps.spec.ts +++ b/e2e/course-maps.spec.ts @@ -179,8 +179,14 @@ test("creates templates, lays out several maps and exports PDFs", async ({ await expect( page .getByTestId("map-course-overlay") - .locator('[data-map-layer="course-overlay"]'), + .locator('[data-map-layer="course-overlay-upper"]'), ).toBeVisible(); + await expect( + page + .getByTestId("map-course-overlay-lower") + .locator('[data-map-layer="course-overlay-lower"]'), + ).toBeVisible(); + await expect(page.getByTestId("map-preview-ink")).toBeVisible(); await expect(page.getByTestId("map-print-margin")).toHaveCount(0); await page.getByTestId("map-panel-properties-toggle").click(); await expect(page.getByText("Template objects are locked")).toBeVisible(); diff --git a/e2e/permissions.spec.ts b/e2e/permissions.spec.ts index 53f4321..61738cd 100644 --- a/e2e/permissions.spec.ts +++ b/e2e/permissions.spec.ts @@ -3,9 +3,14 @@ import { test, expect, type Browser, type Page } from "@playwright/test"; async function inviteUser(page: Page, email: string) { await page.goto("/settings?tab=users"); await expect(page.getByTestId("users-admin-panel")).toBeVisible({ timeout: 15000 }); - await page.getByTestId("invite-email").fill(email); + const emailInput = page.getByTestId("invite-email"); + await emailInput.fill(email); await page.getByTestId("invite-submit").click(); - await expect(page.getByText(email)).toBeVisible({ timeout: 10000 }); + // onSuccess clears the controlled input before invalidating the user list. + // Waiting for that explicit mutation-complete signal avoids racing the + // list refresh on busy full-suite shards. + await expect(emailInput).toHaveValue("", { timeout: 15000 }); + await expect(page.getByText(email)).toBeVisible({ timeout: 20000 }); } async function grantRole(page: Page, email: string, roleName: string) { diff --git a/packages/api/prisma/migrations/20260921180000_stacked_map_tiles/migration.sql b/packages/api/prisma/migrations/20260921180000_stacked_map_tiles/migration.sql new file mode 100644 index 0000000..a07ac7b --- /dev/null +++ b/packages/api/prisma/migrations/20260921180000_stacked_map_tiles/migration.sql @@ -0,0 +1,3 @@ +-- Stacked 256×512 tile format (composite + ink). Pure cache — drop all +-- rows so no legacy 256×256 PNG is served into the client slicer. +TRUNCATE TABLE oxygen.map_tiles; diff --git a/packages/api/prisma/migrations/20260923180000_map_color_profiles_render_keys/migration.sql b/packages/api/prisma/migrations/20260923180000_map_color_profiles_render_keys/migration.sql new file mode 100644 index 0000000..473c237 --- /dev/null +++ b/packages/api/prisma/migrations/20260923180000_map_color_profiles_render_keys/migration.sql @@ -0,0 +1,50 @@ +-- Colour-stack profiles, content-keyed tiles, auto-overprint-cuts toggle. + +-- Event-level switch for automatic circle slits / leg gaps. +ALTER TABLE oxygen.events + ADD COLUMN IF NOT EXISTS auto_overprint_cuts BOOLEAN NOT NULL DEFAULT true; + +-- Map file colour-stack settings + content hash / render key. +ALTER TABLE oxygen.map_files + ADD COLUMN IF NOT EXISTS color_profile TEXT NOT NULL DEFAULT 'auto', + ADD COLUMN IF NOT EXISTS color_overrides JSONB NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS north_lines_below BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN IF NOT EXISTS file_hash TEXT, + ADD COLUMN IF NOT EXISTS render_key TEXT; + +ALTER TABLE oxygen.club_map_files + ADD COLUMN IF NOT EXISTS color_profile TEXT NOT NULL DEFAULT 'auto', + ADD COLUMN IF NOT EXISTS color_overrides JSONB NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS north_lines_below BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN IF NOT EXISTS file_hash TEXT, + ADD COLUMN IF NOT EXISTS render_key TEXT; + +-- Backfill content hashes (render_key is filled lazily on first tile/metadata read). +UPDATE oxygen.map_files +SET file_hash = encode(sha256(file_data), 'hex') +WHERE file_hash IS NULL; + +UPDATE oxygen.club_map_files +SET file_hash = encode(sha256(file_data), 'hex') +WHERE file_hash IS NULL; + +CREATE INDEX IF NOT EXISTS map_files_render_key_idx + ON oxygen.map_files (render_key) + WHERE render_key IS NOT NULL; + +CREATE INDEX IF NOT EXISTS club_map_files_render_key_idx + ON oxygen.club_map_files (render_key) + WHERE render_key IS NOT NULL; + +-- Recreate map_tiles keyed by render_key (shared across events). Pure cache — +-- drop all rows; they regenerate on first view. +DROP TABLE IF EXISTS oxygen.map_tiles; + +CREATE TABLE oxygen.map_tiles ( + render_key TEXT NOT NULL, + z INT NOT NULL, + x INT NOT NULL, + y INT NOT NULL, + tile_data BYTEA NOT NULL, + PRIMARY KEY (render_key, z, x, y) +); diff --git a/packages/api/prisma/migrations/20260924140000_small_object_overprint_cuts/migration.sql b/packages/api/prisma/migrations/20260924140000_small_object_overprint_cuts/migration.sql new file mode 100644 index 0000000..d0aede2 --- /dev/null +++ b/packages/api/prisma/migrations/20260924140000_small_object_overprint_cuts/migration.sql @@ -0,0 +1,9 @@ +-- Existing editor geometry was decorated by v1, which cut long black line +-- and area objects. Keep those rows at v1 so the first geometry read +-- rebuilds every editor course with the small-object-only v2 algorithm. +ALTER TABLE oxygen.events + ADD COLUMN overprint_cuts_version INT NOT NULL DEFAULT 1; + +-- Newly created events already use v2. +ALTER TABLE oxygen.events + ALTER COLUMN overprint_cuts_version SET DEFAULT 2; diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma index 053b272..8c6a02b 100644 --- a/packages/api/prisma/schema.prisma +++ b/packages/api/prisma/schema.prisma @@ -159,6 +159,11 @@ model Event { removed Boolean @default(false) /// Device token for kiosk / start-screen URLs when AUTH_MODE is on. kioskKey String? @map("kiosk_key") + /// When true, editor geometry rebuilds insert automatic circle slits + /// and leg gaps around compact knoll / rock point features. + autoOverprintCuts Boolean @default(true) @map("auto_overprint_cuts") + /// Algorithm version last applied to every editor-authored course. + overprintCutsVersion Int @default(2) @map("overprint_cuts_version") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(6) @@ -177,7 +182,6 @@ model Event { leases EventLease[] mapFiles MapFile[] renderedMaps RenderedMap[] - mapTiles MapTile[] mapTemplates MapTemplate[] courseMaps CourseMap[] graphics Graphic[] @@ -1003,12 +1007,19 @@ model ClubMapFile { rotationCorrection Float @default(0) @map("rotation_correction") /// Diagnostic from import-time north detection (declination, meridian…). northDetection Json? @map("north_detection") + /// IOF colour-stack profile — copied into the event MapFile on use. + colorProfile String @default("auto") @map("color_profile") + colorOverrides Json @default("{}") @map("color_overrides") + northLinesBelow Boolean @default(true) @map("north_lines_below") + fileHash String? @map("file_hash") + renderKey String? @map("render_key") uploadedBy String? @map("uploaded_by") @db.Uuid uploadedAt DateTime @default(now()) @map("uploaded_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) uploader User? @relation(fields: [uploadedBy], references: [id], onDelete: SetNull) + @@index([renderKey]) @@map("club_map_files") @@schema("oxygen") } @@ -1120,10 +1131,21 @@ model MapFile { /// stay club property even after an event adopts them, so downloads /// and event backups of those rows require an instance admin. fromClubLibrary Boolean @default(false) @map("from_club_library") + /// IOF colour-stack profile (`auto` | `isom` | `issprom` | …). + colorProfile String @default("auto") @map("color_profile") + /// Per-colour force-above / force-below overrides (`{ above, below }`). + colorOverrides Json @default("{}") @map("color_overrides") + /// When true, ISOM 601.x north lines are omitted from the ink tile half. + northLinesBelow Boolean @default(true) @map("north_lines_below") + /// SHA-256 hex of `file_data` — content identity for shared tiles. + fileHash String? @map("file_hash") + /// Content-keyed tile/print cache id; lazily filled on first read. + renderKey String? @map("render_key") event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) @@index([eventId]) + @@index([renderKey]) @@map("map_files") @@schema("oxygen") } @@ -1147,15 +1169,15 @@ model RenderedMap { } model MapTile { - eventId BigInt @map("event_id") - z Int - x Int - y Int - tileData Bytes @map("tile_data") - - event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) - - @@id([eventId, z, x, y]) + /// Content-keyed cache identity (see `computeRenderKey`). Shared + /// across events that use the same map blob + colour-stack settings. + renderKey String @map("render_key") + z Int + x Int + y Int + tileData Bytes @map("tile_data") + + @@id([renderKey, z, x, y]) @@map("map_tiles") @@schema("oxygen") } diff --git a/packages/api/src/__tests__/course-map-render.test.ts b/packages/api/src/__tests__/course-map-render.test.ts index 1126626..0f786e7 100644 --- a/packages/api/src/__tests__/course-map-render.test.ts +++ b/packages/api/src/__tests__/course-map-render.test.ts @@ -53,7 +53,8 @@ describe("map page SVG composition", () => { 'width="194" height="194" viewBox="0 0 10000 10000" fill="transparent"', ); expect(svg).toContain('id="terrain"'); - expect(svg).toContain('data-map-layer="course-overlay"'); + expect(svg).toContain('data-map-layer="course-overlay-lower"'); + expect(svg).toContain('data-map-layer="course-overlay-upper"'); expect(svg).toContain('data-pattern-tile="7-7"'); }); }); diff --git a/packages/api/src/__tests__/integration/club-maps.test.ts b/packages/api/src/__tests__/integration/club-maps.test.ts index 1a40d7b..1b1658b 100644 --- a/packages/api/src/__tests__/integration/club-maps.test.ts +++ b/packages/api/src/__tests__/integration/club-maps.test.ts @@ -314,9 +314,10 @@ describe("club map library", () => { data: { geometry: { type: "FeatureCollection", features: [] } }, }); + // Seed an orphan tile row so we can assert GC after the map is replaced. await ctx.db.mapTile.create({ data: { - eventId: ctx.eventId, + renderKey: "orphan-club-maps-seed", z: 1, x: 0, y: 0, @@ -352,7 +353,9 @@ describe("club map library", () => { expect(meta!.calibration!.length).toBeGreaterThanOrEqual(3); expect(meta!.rotationCorrection).toBe(4.5); - const tiles = await ctx.db.mapTile.count({ where: { eventId: ctx.eventId } }); + const tiles = await ctx.db.mapTile.count({ + where: { renderKey: "orphan-club-maps-seed" }, + }); expect(tiles).toBe(0); const after = await ctx.db.course.findFirst({ diff --git a/packages/api/src/__tests__/integration/course-maps.test.ts b/packages/api/src/__tests__/integration/course-maps.test.ts index 8e895dd..be97f58 100644 --- a/packages/api/src/__tests__/integration/course-maps.test.ts +++ b/packages/api/src/__tests__/integration/course-maps.test.ts @@ -529,7 +529,20 @@ describe("course map REST rendering", () => { const pdf = await PDFDocument.load(response.rawPayload); expect(pdf.getPageCount()).toBe(2); expect(convertedSvgs).not.toHaveLength(0); - expect(convertedSvgs.at(-1)).toContain("mix-blend-mode: multiply"); + const last = convertedSvgs.at(-1)!; + expect(last).toContain("course-overlay-lower"); + expect(last).toContain("course-overlay-upper"); + expect(last).not.toContain("mix-blend-mode"); + // Ink (when present) sits between lower and upper purple. + const lowerAt = last.indexOf("course-overlay-lower"); + const upperAt = last.indexOf("course-overlay-upper"); + expect(lowerAt).toBeGreaterThanOrEqual(0); + expect(upperAt).toBeGreaterThan(lowerAt); + const inkAt = last.indexOf('data-map-layer="map-ink"'); + if (inkAt >= 0) { + expect(inkAt).toBeGreaterThan(lowerAt); + expect(inkAt).toBeLessThan(upperAt); + } }); it("exports all-controls starts and finishes as symbols", async () => { diff --git a/packages/api/src/__tests__/integration/map-rotation.test.ts b/packages/api/src/__tests__/integration/map-rotation.test.ts index e4beee5..6eb51ec 100644 --- a/packages/api/src/__tests__/integration/map-rotation.test.ts +++ b/packages/api/src/__tests__/integration/map-rotation.test.ts @@ -51,10 +51,19 @@ describe("course.setMapRotation", () => { lng: 2, }, }); - // Seed a fake cached tile so we can assert it gets deleted. + // Unique stack settings so GC of this event's old key is not blocked + // by other suites that share the same OCAD blob at rotation 0. + await caller.course.setMapColorStack({ + overrides: { above: [313131] }, + }); + // Seed a fake cached tile under the pre-rotation render key so we can + // assert GC drops it when rotation changes the key. + const beforeMeta = await caller.course.mapMetadata(); + expect(beforeMeta?.renderKey).toBeTruthy(); + const oldRenderKey = beforeMeta!.renderKey!; await ctx.db.mapTile.create({ data: { - eventId: ctx.eventId, + renderKey: oldRenderKey, z: 12, x: 1, y: 1, @@ -62,7 +71,7 @@ describe("course.setMapRotation", () => { }, }); - const before = await caller.course.mapMetadata(); + const before = beforeMeta; expect(before).not.toBeNull(); expect(before!.rotationCorrection).toBe(0); @@ -98,9 +107,10 @@ describe("course.setMapRotation", () => { const after = await caller.course.mapMetadata(); expect(after!.rotationCorrection).toBe(11); expect(after!.northOffset).toBeCloseTo(expected.northOffset!, 6); + expect(after!.renderKey).not.toBe(oldRenderKey); const tiles = await ctx.db.mapTile.count({ - where: { eventId: ctx.eventId }, + where: { renderKey: oldRenderKey }, }); expect(tiles).toBe(0); diff --git a/packages/api/src/__tests__/integration/map-tiles.test.ts b/packages/api/src/__tests__/integration/map-tiles.test.ts index b46dbc8..447e4b7 100644 --- a/packages/api/src/__tests__/integration/map-tiles.test.ts +++ b/packages/api/src/__tests__/integration/map-tiles.test.ts @@ -28,6 +28,8 @@ import { resolve } from "path"; import Fastify, { type FastifyInstance } from "fastify"; import { registerMapTileRoutes } from "../../map-tiles.js"; import { createTestEvent, disconnect } from "../helpers/test-db.js"; +import { ensureEventMapRenderKey, gcOrphanTiles } from "../../map-render-cache.js"; +import { makeCaller } from "../helpers/caller.js"; import { ocadBoundsToWgs84, type OcadCrs, @@ -51,6 +53,12 @@ let mapBounds: { const FIXTURE = resolve(__dirname, "../../../../../e2e/test.ocd"); +async function eventRenderKey(eventId: bigint): Promise { + const meta = await ensureEventMapRenderKey(ctx.db, eventId); + if (!meta?.renderKey) throw new Error("missing renderKey"); + return meta.renderKey; +} + /** Slippy-map XYZ coords for the centre of a WGS84 bbox at zoom `z`. */ function centerTile( bounds: { north: number; south: number; east: number; west: number }, @@ -139,6 +147,23 @@ describe("map-tile endpoint", () => { Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), ); expect(res.rawPayload.length).toBeGreaterThan(200); + // Stacked tile: composite (top) + ink (bottom) = 256×512. + const { default: sharp } = await import("sharp"); + const meta = await sharp(res.rawPayload).metadata(); + expect(meta.width).toBe(256); + expect(meta.height).toBe(512); + // Ink half (bottom) must carry some opaque pixels — black paths / + // blue north lines from the fixture colour stack. + const ink = await sharp(res.rawPayload) + .extract({ left: 0, top: 256, width: 256, height: 256 }) + .ensureAlpha() + .raw() + .toBuffer(); + let opaque = 0; + for (let i = 3; i < ink.length; i += 4) { + if (ink[i]! > 0) opaque += 1; + } + expect(opaque).toBeGreaterThan(100); // Second request — same tile, served from the map_tiles cache. const cached = await server.inject({ @@ -149,7 +174,7 @@ describe("map-tile endpoint", () => { expect(cached.rawPayload.length).toBe(res.rawPayload.length); const cachedRows = await ctx.db.mapTile.count({ - where: { eventId: ctx.eventId, z: Z, x, y }, + where: { renderKey: await eventRenderKey(ctx.eventId), z: Z, x, y }, }); expect(cachedRows).toBe(1); }, 60_000); @@ -164,9 +189,10 @@ describe("map-tile endpoint", () => { const bx = Math.floor(x / size) * size; const by = Math.floor(y / size) * size; + const renderKey = await eventRenderKey(ctx.eventId); const before = await ctx.db.mapTile.count({ where: { - eventId: ctx.eventId, + renderKey, z: Z, x: { gte: bx, lt: bx + size }, y: { gte: by, lt: by + size }, @@ -182,7 +208,7 @@ describe("map-tile endpoint", () => { const after = await ctx.db.mapTile.count({ where: { - eventId: ctx.eventId, + renderKey, z: Z, x: { gte: bx, lt: bx + size }, y: { gte: by, lt: by + size }, @@ -273,7 +299,7 @@ describe("map-tile endpoint", () => { // above the ceiling and must not be counted. const rows = await ctx.db.mapTile.count({ where: { - eventId: ctx.eventId, + renderKey: await eventRenderKey(ctx.eventId), z: { gte: PRECACHE_MIN_ZOOM, lte: PRECACHE_MAX_ZOOM }, }, }); @@ -342,13 +368,15 @@ describe("map-tile endpoint", () => { process.env.MAP_TILE_PRECACHE = "on"; process.env.MAP_PRECACHE_BLOCK_DELAY_MS = "0"; try { - await other.db.mapFile.create({ - data: { - eventId: other.eventId, - fileName: "test.ocd", - fileData: readFileSync(FIXTURE), - bounds: mapBounds, - }, + const caller = makeCaller(other.event); + await caller.course.uploadMap({ + fileName: "test.ocd", + fileDataBase64: readFileSync(FIXTURE).toString("base64"), + }); + // Unique stack settings so this event's render_key does not share + // deep-zoom tiles rendered earlier by the suite's primary event. + await caller.course.setMapColorStack({ + overrides: { above: [424242] }, }); const { x, y } = centerTile(mapBounds, PRECACHE_MIN_ZOOM); @@ -377,7 +405,11 @@ describe("map-tile endpoint", () => { // Nothing above the ceiling was rendered — that is the point of it. const deep = await other.db.mapTile.count({ - where: { eventId: other.eventId, z: { gt: PRECACHE_MAX_ZOOM } }, + where: { + renderKey: (await ensureEventMapRenderKey(other.db, other.eventId))! + .renderKey, + z: { gt: PRECACHE_MAX_ZOOM }, + }, }); expect(deep).toBe(0); } finally { @@ -416,13 +448,17 @@ describe("map-tile endpoint", () => { expect(first.statusCode).toBe(200); // What a re-upload looks like from another instance's point of - // view: new bytes, a newer `uploadedAt`, and the event's tiles - // purged — with no in-process notification. - await other.db.mapTile.deleteMany({ where: { eventId: other.eventId } }); + // view: new bytes, a newer `uploadedAt`, and the old render_key + // cleared — with no in-process notification. + const oldKey = (await ensureEventMapRenderKey(other.db, other.eventId))! + .renderKey; + await other.db.mapTile.deleteMany({ where: { renderKey: oldKey } }); await other.db.mapFile.updateMany({ where: { eventId: other.eventId }, data: { fileData: Buffer.from("not an ocad file"), + fileHash: null, + renderKey: null, uploadedAt: new Date(Date.now() + 60_000), }, }); @@ -466,4 +502,125 @@ describe("map-tile endpoint", () => { expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ total: 0, done: 0, rendering: false }); }); + + it("two events with the same map blob share tile rows", async () => { + const a = await createTestEvent("map_tiles_share_a"); + const b = await createTestEvent("map_tiles_share_b"); + try { + const buf = readFileSync(FIXTURE); + const callerA = makeCaller(a.event); + const callerB = makeCaller(b.event); + await callerA.course.uploadMap({ + fileName: "test.ocd", + fileDataBase64: buf.toString("base64"), + }); + await callerB.course.uploadMap({ + fileName: "test.ocd", + fileDataBase64: buf.toString("base64"), + }); + const keyA = (await ensureEventMapRenderKey(a.db, a.eventId))!.renderKey; + const keyB = (await ensureEventMapRenderKey(b.db, b.eventId))!.renderKey; + expect(keyA).toBe(keyB); + + const Z = 13; + const { x, y } = centerTile(mapBounds, Z); + const first = await server.inject({ + method: "GET", + url: `/api/map-tile/${a.nameId}/${Z}/${x}/${y}`, + }); + expect(first.statusCode).toBe(200); + const rowsAfterA = await a.db.mapTile.count({ + where: { renderKey: keyA, z: Z, x, y }, + }); + expect(rowsAfterA).toBe(1); + + const second = await server.inject({ + method: "GET", + url: `/api/map-tile/${b.nameId}/${Z}/${x}/${y}`, + }); + expect(second.statusCode).toBe(200); + expect(second.rawPayload.length).toBe(first.rawPayload.length); + // Still one row — B hit A's cache. + expect( + await a.db.mapTile.count({ where: { renderKey: keyA, z: Z, x, y } }), + ).toBe(1); + } finally { + await a.cleanup(); + await b.cleanup(); + } + }, 90_000); + + it("changing colour profile yields a new key; GC drops unreferenced tiles", async () => { + const ev = await createTestEvent("map_tiles_profile"); + try { + const caller = makeCaller(ev.event); + await caller.course.uploadMap({ + fileName: "test.ocd", + fileDataBase64: readFileSync(FIXTURE).toString("base64"), + }); + // Mint a unique key so the suite's primary event (same blob + auto) + // does not keep the old key alive after we switch profile. + await caller.course.setMapColorStack({ + overrides: { above: [777001] }, + }); + const oldKey = (await ensureEventMapRenderKey(ev.db, ev.eventId))! + .renderKey; + const Z = 13; + const { x, y } = centerTile(mapBounds, Z); + await server.inject({ + method: "GET", + url: `/api/map-tile/${ev.nameId}/${Z}/${x}/${y}`, + }); + expect( + await ev.db.mapTile.count({ where: { renderKey: oldKey, z: Z, x, y } }), + ).toBe(1); + + const result = await caller.course.setMapColorStack({ profile: "issprom" }); + expect(result.renderKey).not.toBe(oldKey); + await gcOrphanTiles(ev.db); + expect( + await ev.db.mapTile.count({ where: { renderKey: oldKey } }), + ).toBe(0); + + const meta = await caller.course.mapMetadata(); + expect(meta?.renderKey).toBe(result.renderKey); + expect(meta?.colorProfile).toBe("issprom"); + expect(meta?.cutRotationDeg).toBeDefined(); + } finally { + await ev.cleanup(); + } + }, 90_000); + + it("club-library map keeps tiles after the event drops the map", async () => { + const clubCaller = makeCaller(null); + const uploaded = await clubCaller.clubMap.upload({ + fileName: "test.ocd", + fileDataBase64: readFileSync(FIXTURE).toString("base64"), + }); + const ev = await createTestEvent("map_tiles_club_keep"); + try { + const caller = makeCaller(ev.event); + await caller.course.useClubMap({ clubMapId: uploaded.id }); + const key = (await ensureEventMapRenderKey(ev.db, ev.eventId))!.renderKey; + const Z = 13; + const { x, y } = centerTile(mapBounds, Z); + await server.inject({ + method: "GET", + url: `/api/map-tile/${ev.nameId}/${Z}/${x}/${y}`, + }); + expect( + await ev.db.mapTile.count({ where: { renderKey: key, z: Z, x, y } }), + ).toBe(1); + + await ev.cleanup(); + await gcOrphanTiles(ev.db); + // Club library still references the key → tiles survive. + expect( + await ev.db.mapTile.count({ where: { renderKey: key, z: Z, x, y } }), + ).toBe(1); + } finally { + await clubCaller.clubMap.remove({ id: uploaded.id }); + await gcOrphanTiles(ev.db); + } + }, 90_000); }); diff --git a/packages/api/src/__tests__/integration/overprint-cuts.test.ts b/packages/api/src/__tests__/integration/overprint-cuts.test.ts index 3732aaf..53bd948 100644 --- a/packages/api/src/__tests__/integration/overprint-cuts.test.ts +++ b/packages/api/src/__tests__/integration/overprint-cuts.test.ts @@ -2,7 +2,7 @@ * Integration tests for automatic overprint cuts: circle slits and leg * gaps computed from the base map at geometry-rebuild time. * - * Uses the synthetic OCAD fixture's known black features in the otherwise + * Uses the synthetic OCAD fixture's known features in the otherwise * empty top-right corner (see `scripts/generate-test-ocd.mjs`): a boulder * (ISOM 204) at 68/42 mm and a building (ISOM 521) spanning * 48–58 / 39–46 mm. @@ -71,7 +71,7 @@ describe("automatic overprint cuts in editor geometry", () => { expect(inEmpty?.properties.cuts).toBeUndefined(); }); - it("gaps a leg crossing the building", async () => { + it("does not gap a leg crossing a building", async () => { await caller.control.create({ codes: "163", xpos: 44, ypos: 42 }); await caller.control.create({ codes: "164", xpos: 64, ypos: 42 }); const course = await caller.course.create({ @@ -83,11 +83,25 @@ describe("automatic overprint cuts in editor geometry", () => { const leg = geom.features.find( (f) => f.properties.from === "163" && f.properties.to === "164", ); + expect(leg?.properties.gaps).toBeUndefined(); + }); + + it("gaps a leg crossing the boulder", async () => { + await caller.control.create({ codes: "167", xpos: 60, ypos: 42 }); + await caller.control.create({ codes: "168", xpos: 76, ypos: 42 }); + const course = await caller.course.create({ + name: "Cuts-Boulder-Leg", + controlIds: [167, 168], + }); + + const geom = await storedGeometry(course.id); + const leg = geom.features.find( + (f) => f.properties.from === "167" && f.properties.to === "168", + ); const gaps = leg?.properties.gaps as Array<{ from: number; to: number }>; expect(gaps).toHaveLength(1); - // Building spans x 48–58 on the 44→64 leg → fractions ≈ 0.2–0.7. - expect(gaps[0].from).toBeCloseTo(0.2, 1); - expect(gaps[0].to).toBeCloseTo(0.7, 1); + expect(gaps[0].from).toBeLessThan(0.5); + expect(gaps[0].to).toBeGreaterThan(0.5); }); it("recomputes cuts when a control moves", async () => { @@ -154,4 +168,102 @@ describe("automatic overprint cuts in editor geometry", () => { await other.cleanup(); } }); + + it("lazily rebuilds editor geometry stored by the previous cut algorithm", async () => { + const other = await createTestEvent("overprint_cuts_version"); + try { + const otherCaller = makeCaller(other.event); + await otherCaller.course.uploadMap({ + fileName: "test.ocd", + fileDataBase64: readFileSync(FIXTURE).toString("base64"), + }); + await otherCaller.control.create({ codes: "175", xpos: 44, ypos: 42 }); + await otherCaller.control.create({ codes: "176", xpos: 64, ypos: 42 }); + const course = await otherCaller.course.create({ + name: "Legacy long-object gap", + controlIds: [175, 176], + }); + const row = await other.db.course.findFirstOrThrow({ + where: { eventId: other.eventId, seq: course.id }, + }); + const stale = row.geometry as unknown as GeoJSONFeatureCollection; + const leg = stale.features.find( + (feature) => + feature.properties.from === "175" && + feature.properties.to === "176", + ); + leg!.properties.gaps = [{ from: 0.2, to: 0.7 }]; + await other.db.course.update({ + where: { id: row.id }, + data: { geometry: stale as never }, + }); + await other.db.event.update({ + where: { id: other.eventId }, + data: { overprintCutsVersion: 1 }, + }); + + const rebuilt = await otherCaller.course.geometry({ id: course.id }); + const rebuiltLeg = rebuilt.features.find( + (feature) => + (feature as GeoJSONFeatureCollection["features"][number]).properties + .from === "175", + ) as GeoJSONFeatureCollection["features"][number]; + expect(rebuiltLeg.properties.gaps).toBeUndefined(); + expect( + ( + await other.db.event.findUniqueOrThrow({ + where: { id: other.eventId }, + }) + ).overprintCutsVersion, + ).toBe(2); + } finally { + await other.cleanup(); + } + }); + + it("setOverprintCuts(false) strips cuts/gaps; true restores them", async () => { + await caller.control.create({ codes: "181", xpos: 68, ypos: 39.5 }); + await caller.control.create({ codes: "182", xpos: 60, ypos: 42 }); + await caller.control.create({ codes: "183", xpos: 76, ypos: 42 }); + const course = await caller.course.create({ + name: "Cuts-Toggle", + controlIds: [181, 182, 183], + }); + + let geom = await storedGeometry(course.id); + expect( + geom.features.find((f) => f.properties.code === "181")?.properties.cuts, + ).toHaveLength(1); + expect( + geom.features.find( + (f) => f.properties.from === "182" && f.properties.to === "183", + )?.properties.gaps, + ).toHaveLength(1); + + const off = await caller.course.setOverprintCuts({ enabled: false }); + expect(off.enabled).toBe(false); + expect((await caller.course.getOverprintCuts()).enabled).toBe(false); + + geom = await storedGeometry(course.id); + expect( + geom.features.find((f) => f.properties.code === "181")?.properties.cuts, + ).toBeUndefined(); + expect( + geom.features.find( + (f) => f.properties.from === "182" && f.properties.to === "183", + )?.properties.gaps, + ).toBeUndefined(); + + const on = await caller.course.setOverprintCuts({ enabled: true }); + expect(on.enabled).toBe(true); + geom = await storedGeometry(course.id); + expect( + geom.features.find((f) => f.properties.code === "181")?.properties.cuts, + ).toHaveLength(1); + expect( + geom.features.find( + (f) => f.properties.from === "182" && f.properties.to === "183", + )?.properties.gaps, + ).toHaveLength(1); + }); }); diff --git a/packages/api/src/__tests__/integration/reset-auto-north-corrections.test.ts b/packages/api/src/__tests__/integration/reset-auto-north-corrections.test.ts index 3325f8d..b955592 100644 --- a/packages/api/src/__tests__/integration/reset-auto-north-corrections.test.ts +++ b/packages/api/src/__tests__/integration/reset-auto-north-corrections.test.ts @@ -49,7 +49,22 @@ function migrationStatements(): string[] { .join("\n") .split(";") .map((s) => s.trim()) - .filter(Boolean); + .filter(Boolean) + // map_tiles dropped event_id in 20260923180000; rewrite the historical + // DELETE to the content-keyed form so this suite still validates the + // correction-reset logic against the current schema. + .map((sql) => + sql.includes("DELETE FROM oxygen.map_tiles") + ? `DELETE FROM oxygen.map_tiles t +USING oxygen.map_files m +WHERE t.render_key = m.render_key + AND m.rotation_correction <> 0 + AND m.north_detection IS NOT NULL + AND (m.north_detection->>'shouldAutoApply')::boolean IS TRUE + AND (m.north_detection->>'suggestedCorrectionDeg') IS NOT NULL + AND abs(m.rotation_correction - (m.north_detection->>'suggestedCorrectionDeg')::double precision) < 1e-6` + : sql, + ); } let autoCtx: TestEventContext; @@ -88,10 +103,17 @@ describe("reset_auto_north_corrections migration", () => { bounds: shifted.bounds as unknown as Prisma.InputJsonValue, northOffset: shifted.northOffset, calibration: shifted.calibration as unknown as Prisma.InputJsonValue, + renderKey: "reset-auto-key", }, }); await db.mapTile.create({ - data: { eventId: autoCtx.eventId, z: 3, x: 1, y: 1, tileData: Buffer.from([1]) }, + data: { + renderKey: "reset-auto-key", + z: 3, + x: 1, + y: 1, + tileData: Buffer.from([1]), + }, }); // A positioned control whose lat/lng were computed under +1.7°. const control = await db.control.create({ @@ -111,10 +133,17 @@ describe("reset_auto_north_corrections migration", () => { bounds: shifted.bounds as unknown as Prisma.InputJsonValue, northOffset: shifted.northOffset, calibration: shifted.calibration as unknown as Prisma.InputJsonValue, + renderKey: "reset-manual-key", }, }); await manualCtx.db.mapTile.create({ - data: { eventId: manualCtx.eventId, z: 3, x: 1, y: 1, tileData: Buffer.from([1]) }, + data: { + renderKey: "reset-manual-key", + z: 3, + x: 1, + y: 1, + tileData: Buffer.from([1]), + }, }); // Club-library rows: one auto, one manual. @@ -155,7 +184,7 @@ describe("reset_auto_north_corrections migration", () => { expect(autoAfter.calibration).toBeNull(); expect(autoAfter.uploadedAt.getTime()).toBeGreaterThan(autoRow.uploadedAt.getTime()); expect(autoAfter.northDetection).toMatchObject({ autoCorrectionResetFromDeg: 1.7 }); - expect(await db.mapTile.count({ where: { eventId: autoCtx.eventId } })).toBe(0); + expect(await db.mapTile.count({ where: { renderKey: "reset-auto-key" } })).toBe(0); const manualAfter = await manualCtx.db.mapFile.findUniqueOrThrow({ where: { id: manualRow.id }, @@ -164,7 +193,7 @@ describe("reset_auto_north_corrections migration", () => { expect(manualAfter.scale).toBe(shifted.scale); expect(manualAfter.calibration).not.toBeNull(); expect( - await manualCtx.db.mapTile.count({ where: { eventId: manualCtx.eventId } }), + await manualCtx.db.mapTile.count({ where: { renderKey: "reset-manual-key" } }), ).toBe(1); const clubAutoAfter = await db.clubMapFile.findUniqueOrThrow({ where: { id: clubAuto.id } }); diff --git a/packages/api/src/__tests__/map-color-stack.test.ts b/packages/api/src/__tests__/map-color-stack.test.ts new file mode 100644 index 0000000..bb8e6c1 --- /dev/null +++ b/packages/api/src/__tests__/map-color-stack.test.ts @@ -0,0 +1,400 @@ +import { describe, expect, it } from "vitest"; +import { + applyIofColorStack, + filterNorthLineObjects, + inkToColor, + isAboveLowerPurple, + resolveColorProfile, + type StackColor, + type StackOcadFile, + type StackSymbol, +} from "../map-color-stack.js"; + +function color( + number: number, + cmyk: [number, number, number, number], + renderOrder: number, + name = `c${number}`, +): StackColor { + return { number, cmyk, renderOrder, name }; +} + +function file( + colors: StackColor[], + symbols: StackSymbol[], +): StackOcadFile { + // Sparse array indexed by colour number, matching ocad2geojson. + const sparse: Array = []; + for (const c of colors) sparse[c.number] = c; + return { colors: sparse, symbols }; +} + +describe("isAboveLowerPurple", () => { + it("puts black 100% used by lines above (all profiles)", () => { + const usage = { line: true, point: false, area: false, text: false }; + expect(isAboveLowerPurple(color(1, [0, 0, 0, 100], 0), usage, "isom")).toBe(true); + expect(isAboveLowerPurple(color(1, [0, 0, 0, 100], 0), usage, "issprom")).toBe(true); + }); + + it("keeps area-only black (big buildings) below", () => { + expect( + isAboveLowerPurple(color(1, [0, 0, 0, 100], 0), { + line: false, + point: false, + area: true, + text: false, + }), + ).toBe(false); + }); + + it("puts brown 100% lines above only in ISOM", () => { + const usage = { line: true, point: false, area: false, text: false }; + expect(isAboveLowerPurple(color(5, [0, 56, 100, 18], 0), usage, "isom")).toBe(true); + expect(isAboveLowerPurple(color(5, [25, 75, 100, 0], 0), usage, "isom")).toBe(true); + expect(isAboveLowerPurple(color(5, [25, 75, 100, 0], 0), usage, "issprom")).toBe(false); + expect(isAboveLowerPurple(color(5, [25, 75, 100, 0], 0), usage, "ismtbom")).toBe(false); + }); + + it("puts blue 100% line above in ISOM/SkiO/MTBO but not ISSprOM", () => { + const usage = { line: true, point: false, area: false, text: false }; + expect(isAboveLowerPurple(color(3, [100, 0, 0, 0], 0), usage, "isom")).toBe(true); + expect(isAboveLowerPurple(color(3, [100, 0, 0, 0], 0), usage, "isskiom")).toBe(true); + expect(isAboveLowerPurple(color(3, [100, 0, 0, 0], 0), usage, "ismtbom")).toBe(true); + expect(isAboveLowerPurple(color(3, [100, 0, 0, 0], 0), usage, "issprom")).toBe(false); + }); + + it("puts white line/point above but area knockouts below", () => { + expect( + isAboveLowerPurple(color(9, [0, 0, 0, 0], 0), { + line: true, + point: false, + area: false, + text: false, + }), + ).toBe(true); + expect( + isAboveLowerPurple(color(9, [0, 0, 0, 0], 0), { + line: false, + point: false, + area: true, + text: false, + }), + ).toBe(false); + }); + + it("puts green 100% only when used exclusively by points (ISOM)", () => { + expect( + isAboveLowerPurple(color(7, [76, 0, 91, 0], 0), { + line: false, + point: true, + area: false, + text: false, + }, "isom"), + ).toBe(true); + expect( + isAboveLowerPurple(color(7, [76, 0, 91, 0], 0), { + line: false, + point: false, + area: true, + text: false, + }, "isom"), + ).toBe(false); + }); + + it("puts Ski-O green line above in isskiom", () => { + const usage = { line: true, point: false, area: false, text: false }; + expect(isAboveLowerPurple(color(8, [91, 0, 83, 0], 0), usage, "isskiom")).toBe(true); + expect(isAboveLowerPurple(color(8, [91, 0, 83, 0], 0), usage, "isom")).toBe(false); + }); + + it("puts purple 50% area above in ISSprOM and ISMTBOM", () => { + const usage = { line: false, point: false, area: true, text: false }; + expect(isAboveLowerPurple(color(20, [18, 43, 0, 0], 0), usage, "issprom")).toBe(true); + expect(isAboveLowerPurple(color(20, [18, 43, 0, 0], 0), usage, "ismtbom")).toBe(true); + expect(isAboveLowerPurple(color(20, [18, 43, 0, 0], 0), usage, "isom")).toBe(false); + }); + + it("keeps course purple below regardless of usage", () => { + expect( + isAboveLowerPurple(color(0, [35, 85, 0, 0], 0), { + line: true, + point: true, + area: false, + text: false, + }), + ).toBe(false); + }); + + it("keeps yellow and vegetation below", () => { + const none = { line: false, point: false, area: true, text: false }; + expect(isAboveLowerPurple(color(2, [0, 27, 79, 0], 0), none)).toBe(false); + expect(isAboveLowerPurple(color(8, [46, 0, 55, 0], 0), none)).toBe(false); + }); +}); + +describe("resolveColorProfile", () => { + it("returns explicit profiles unchanged", () => { + expect(resolveColorProfile("isom", 4000)).toEqual({ + resolvedProfile: "isom", + resolvedBy: "explicit", + }); + }); + + it("picks ISSprOM for sprint scales and ISOM otherwise", () => { + expect(resolveColorProfile("auto", 4000)).toEqual({ + resolvedProfile: "issprom", + resolvedBy: "scale", + }); + expect(resolveColorProfile("auto", 5000)).toEqual({ + resolvedProfile: "issprom", + resolvedBy: "scale", + }); + expect(resolveColorProfile("auto", 15000)).toEqual({ + resolvedProfile: "isom", + resolvedBy: "scale", + }); + expect(resolveColorProfile("auto", null)).toEqual({ + resolvedProfile: "isom", + resolvedBy: "default", + }); + }); +}); + +describe("applyIofColorStack", () => { + it("rewrites renderOrder so ink colours sit at 0..boundary-1", () => { + // Deliberately wrong file order: yellow first, then black, then blue. + const colors = [ + color(2, [0, 27, 79, 0], 0, "Yellow"), + color(1, [0, 0, 0, 100], 1, "Black"), + color(3, [100, 0, 0, 0], 2, "Blue"), + color(5, [0, 56, 100, 18], 3, "Brown"), + ]; + const symbols: StackSymbol[] = [ + { type: 2, lineColor: 1 }, + { type: 2, lineColor: 3 }, + { type: 2, lineColor: 5 }, + { type: 3, fillColor: 2 }, + ]; + const result = applyIofColorStack(file(colors, symbols), { profile: "isom" }); + expect(result.above).toEqual([1, 3, 5]); + expect(result.below).toEqual([2]); + expect(result.boundary).toBe(3); + expect(inkToColor(result)).toBe(2); + expect(result.resolvedProfile).toBe("isom"); + expect(result.resolvedBy).toBe("explicit"); + // Relative order of above colours preserved from the file. + expect(colors.find((c) => c.number === 1)!.renderOrder).toBe(0); + expect(colors.find((c) => c.number === 3)!.renderOrder).toBe(1); + expect(colors.find((c) => c.number === 5)!.renderOrder).toBe(2); + expect(colors.find((c) => c.number === 2)!.renderOrder).toBe(3); + }); + + it("keeps brown and blue below in ISSprOM", () => { + const colors = [ + color(1, [0, 0, 0, 100], 0, "Black"), + color(3, [100, 0, 0, 0], 1, "Blue"), + color(5, [25, 75, 100, 0], 2, "Brown"), + color(2, [0, 27, 79, 0], 3, "Yellow"), + ]; + const symbols: StackSymbol[] = [ + { type: 2, lineColor: 1 }, + { type: 2, lineColor: 3 }, + { type: 2, lineColor: 5 }, + { type: 3, fillColor: 2 }, + ]; + const result = applyIofColorStack(file(colors, symbols), { profile: "issprom" }); + expect(result.above).toEqual([1]); + expect(result.below).toEqual([3, 5, 2]); + expect(result.resolvedProfile).toBe("issprom"); + }); + + it("auto-picks ISSprOM from sprint scale", () => { + const colors = [ + color(1, [0, 0, 0, 100], 0, "Black"), + color(5, [25, 75, 100, 0], 1, "Brown"), + ]; + const symbols: StackSymbol[] = [ + { type: 2, lineColor: 1 }, + { type: 2, lineColor: 5 }, + ]; + const result = applyIofColorStack(file(colors, symbols), { + profile: "auto", + scale: 4000, + }); + expect(result.resolvedBy).toBe("scale"); + expect(result.resolvedProfile).toBe("issprom"); + expect(result.above).toEqual([1]); + expect(result.below).toEqual([5]); + }); + + it("auto uses the file's Lower purple colour as the boundary", () => { + // File already has Lower purple between black/blue and yellow. + const colors = [ + color(1, [0, 0, 0, 100], 0, "Black"), + color(3, [100, 0, 0, 0], 1, "Blue"), + color(0, [35, 85, 0, 0], 2, "Lower purple for course overprint"), + color(2, [0, 27, 79, 0], 3, "Yellow"), + color(5, [0, 56, 100, 18], 4, "Brown 50%"), // wrongly below in file — kept + ]; + const symbols: StackSymbol[] = [ + { type: 2, lineColor: 1 }, + { type: 2, lineColor: 3 }, + { type: 3, fillColor: 2 }, + { type: 3, fillColor: 5 }, + ]; + const result = applyIofColorStack(file(colors, symbols), { profile: "auto" }); + expect(result.resolvedBy).toBe("file-colour"); + expect(result.above).toEqual([1, 3]); + expect(result.below).toEqual([0, 2, 5]); + expect(result.boundary).toBe(2); + }); + + it("recognises Swedish Undre lila as the lower-purple marker", () => { + const colors = [ + color(1, [0, 0, 0, 100], 0, "Svart"), + color(0, [35, 85, 0, 0], 1, "Undre lila för banpåtryck"), + color(2, [0, 27, 79, 0], 2, "Gul"), + ]; + const symbols: StackSymbol[] = [ + { type: 2, lineColor: 1 }, + { type: 3, fillColor: 2 }, + ]; + const result = applyIofColorStack(file(colors, symbols), { profile: "auto" }); + expect(result.resolvedBy).toBe("file-colour"); + expect(result.above).toEqual([1]); + expect(result.below).toEqual([0, 2]); + }); + + it("applies force-above / force-below overrides", () => { + const colors = [ + color(1, [0, 0, 0, 100], 0, "Black"), + color(5, [0, 56, 100, 18], 1, "Brown"), + color(2, [0, 27, 79, 0], 2, "Yellow"), + ]; + const symbols: StackSymbol[] = [ + { type: 2, lineColor: 1 }, + { type: 2, lineColor: 5 }, + { type: 3, fillColor: 2 }, + ]; + const result = applyIofColorStack(file(colors, symbols), { + profile: "isom", + overrides: { below: [5], above: [2] }, + }); + expect(result.above).toEqual([1, 2]); + expect(result.below).toEqual([5]); + }); + + it("keeps area-only black below while line black goes above", () => { + const colors = [ + color(10, [0, 0, 0, 100], 0, "Black line"), + color(11, [0, 0, 0, 60], 1, "Black 60% buildings"), + color(12, [0, 0, 0, 100], 2, "Black area buildings"), + ]; + const symbols: StackSymbol[] = [ + { type: 2, lineColor: 10 }, + { type: 3, fillColor: 11 }, + { type: 3, fillColor: 12 }, + ]; + const result = applyIofColorStack(file(colors, symbols)); + expect(result.above).toEqual([10]); + expect(result.below).toEqual([11, 12]); + }); + + it("handles the E2E fixture colour table", () => { + // scripts/lib/ocad-fixture.mjs DEFAULT_COLORS: + // Purple n0, Black n1 (path+boulder+building), Yellow n2, Blue n3 (north). + const colors = [ + color(0, [35, 85, 0, 0], 0, "Purple"), + color(1, [0, 0, 0, 100], 1, "Black"), + color(2, [0, 27, 79, 0], 2, "Yellow"), + color(3, [100, 0, 0, 0], 3, "Blue"), + ]; + const symbols: StackSymbol[] = [ + { type: 2, lineColor: 1 }, // path + { type: 1, elements: [{ color: 1 }] }, // boulder + { type: 3, fillColor: 1 }, // building (also uses black) + { type: 3, fillColor: 2 }, // yellow open land + { type: 2, lineColor: 3 }, // magnetic north + { type: 1, elements: [{ color: 0 }] }, // course control + ]; + const result = applyIofColorStack(file(colors, symbols)); + expect(result.above).toEqual([1, 3]); + expect(result.below).toEqual([0, 2]); + expect(result.boundary).toBe(2); + expect(result.warnings).toEqual([]); + }); + + it("returns boundary 0 and a warning when nothing is above", () => { + const colors = [color(2, [0, 27, 79, 0], 0, "Yellow")]; + const symbols: StackSymbol[] = [{ type: 3, fillColor: 2 }]; + const result = applyIofColorStack(file(colors, symbols)); + expect(result.boundary).toBe(0); + expect(result.above).toEqual([]); + expect(result.below).toEqual([2]); + expect(inkToColor(result)).toBeNull(); + expect(result.warnings[0]).toMatch(/no black\/brown\/blue/i); + }); + + it("reads double-line and framing colours as line usage", () => { + const colors = [ + color(1, [0, 0, 0, 100], 0, "Black"), + color(4, [0, 0, 0, 100], 1, "Frame black"), + ]; + const symbols: StackSymbol[] = [ + { + type: 2, + lineColor: 1, + frColor: 4, + doubleLine: { + dblLeftColor: 1, + dblRightColor: 1, + dblFillColor: 1, + }, + }, + ]; + const result = applyIofColorStack(file(colors, symbols)); + expect(result.above).toEqual([1, 4]); + }); + + it("leaves a standards-compliant order unchanged in relative above/below", () => { + const colors = [ + color(1, [0, 0, 0, 100], 0, "Black"), + color(3, [100, 0, 0, 0], 1, "Blue"), + color(5, [0, 56, 100, 18], 2, "Brown"), + color(6, [76, 0, 91, 0], 3, "Green"), + color(2, [0, 27, 79, 0], 4, "Yellow"), + ]; + const symbols: StackSymbol[] = [ + { type: 2, lineColor: 1 }, + { type: 2, lineColor: 3 }, + { type: 2, lineColor: 5 }, + { type: 3, fillColor: 6 }, + { type: 3, fillColor: 2 }, + ]; + const result = applyIofColorStack(file(colors, symbols)); + expect(result.above).toEqual([1, 3, 5]); + expect(result.below).toEqual([6, 2]); + expect(result.boundary).toBe(3); + }); +}); + +describe("filterNorthLineObjects", () => { + it("drops ISOM 601.x symbols and keeps everything else", () => { + const objects = [ + { sym: 601001 }, + { sym: 601002 }, + { sym: 109001 }, + { sym: 301000 }, + { sym: undefined }, + ]; + expect(filterNorthLineObjects(objects)?.map((o) => o.sym)).toEqual([ + 109001, + 301000, + undefined, + ]); + }); + + it("returns undefined for undefined input", () => { + expect(filterNorthLineObjects(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/api/src/__tests__/map-north.test.ts b/packages/api/src/__tests__/map-north.test.ts index cb8e856..c81230a 100644 --- a/packages/api/src/__tests__/map-north.test.ts +++ b/packages/api/src/__tests__/map-north.test.ts @@ -3,6 +3,7 @@ import { computeDeclination, computeMeridianStalenessDeg, computeTrueNorthFromGrid, + cutRotationDeg, detectMapNorth, displayNorthOffsetDeg, foldLineAngleDeg, @@ -285,6 +286,19 @@ describe("displayNorthOffsetDeg", () => { }); }); +describe("cutRotationDeg", () => { + it("subtracts meridian tilt from display northOffset", () => { + // Display northOffset 6.0 = paper 2.7 + tilt 3.3 → cuts need 2.7. + expect(cutRotationDeg(6.0, 3.3)).toBeCloseTo(2.7, 5); + }); + + it("passes northOffset through when tilt is missing", () => { + expect(cutRotationDeg(2.7, null)).toBe(2.7); + expect(cutRotationDeg(2.7, undefined)).toBe(2.7); + expect(cutRotationDeg(null, 3.3)).toBeNull(); + }); +}); + describe("roundTenths", () => { it("rounds to one decimal", () => { expect(roundTenths(4.49)).toBe(4.5); diff --git a/packages/api/src/__tests__/map-render-key.test.ts b/packages/api/src/__tests__/map-render-key.test.ts new file mode 100644 index 0000000..28ee764 --- /dev/null +++ b/packages/api/src/__tests__/map-render-key.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { computeRenderKey, hashMapFileData } from "../map-render-key.js"; + +describe("computeRenderKey", () => { + const base = { + fileHash: "abc123", + rotationCorrection: 0, + colorProfile: "auto" as const, + northLinesBelow: true, + }; + + it("is stable for identical inputs", () => { + expect(computeRenderKey(base)).toBe(computeRenderKey({ ...base })); + }); + + it("changes when the profile changes", () => { + expect(computeRenderKey(base)).not.toBe( + computeRenderKey({ ...base, colorProfile: "issprom" }), + ); + }); + + it("changes when northLinesBelow flips", () => { + expect(computeRenderKey(base)).not.toBe( + computeRenderKey({ ...base, northLinesBelow: false }), + ); + }); + + it("changes when rotation correction changes", () => { + expect(computeRenderKey(base)).not.toBe( + computeRenderKey({ ...base, rotationCorrection: 1.5 }), + ); + }); + + it("normalises override array order", () => { + const a = computeRenderKey({ + ...base, + colorOverrides: { above: [3, 1], below: [2] }, + }); + const b = computeRenderKey({ + ...base, + colorOverrides: { above: [1, 3], below: [2] }, + }); + expect(a).toBe(b); + }); + + it("returns a 32-char hex string", () => { + expect(computeRenderKey(base)).toMatch(/^[0-9a-f]{32}$/); + }); +}); + +describe("hashMapFileData", () => { + it("hashes bytes deterministically", () => { + const buf = Buffer.from("hello ocad"); + expect(hashMapFileData(buf)).toBe(hashMapFileData(buf)); + expect(hashMapFileData(buf)).toHaveLength(64); + expect(hashMapFileData(buf)).not.toBe(hashMapFileData(Buffer.from("other"))); + }); +}); diff --git a/packages/api/src/__tests__/overprint-cuts.test.ts b/packages/api/src/__tests__/overprint-cuts.test.ts index b79aa41..4dd38e1 100644 --- a/packages/api/src/__tests__/overprint-cuts.test.ts +++ b/packages/api/src/__tests__/overprint-cuts.test.ts @@ -1,8 +1,7 @@ /** * Unit tests for the automatic overprint-cut computation - * (`overprint-cuts.ts`): circle slits over black features and knolls, - * leg gaps over black features, merging and sanity caps, and the - * geometry decorator. + * (`overprint-cuts.ts`): circle slits and leg gaps over small rock + * features and knolls, plus merging and the geometry decorator. */ import { describe, it, expect } from "vitest"; @@ -74,38 +73,14 @@ describe("circleCuts", () => { expect(circleCuts([waterhole, tree], 0, 0)).toEqual([]); }); - it("slits both crossings of a black line through the circle", () => { - // Vertical path 1 mm east of centre crosses the rim twice (NNE + SSE). + it("does not slit long line objects", () => { const path = obj(505000, 2, [[1, -10], [1, 10]]); - const cuts = circleCuts([path], 0, 0); - expect(cuts).toHaveLength(2); - const eastOfNorth = Math.round((Math.asin(1 / CIRCLE_RADIUS_MM) * 180) / Math.PI); - expect(gapAt(cuts, eastOfNorth)).toBe(true); - expect(gapAt(cuts, 180 - eastOfNorth)).toBe(true); - expect(gapAt(cuts, 270)).toBe(false); - // Each crossing clears the line and no more — the two slits stay - // well apart instead of merging into one long arc. - for (const cut of cuts) { - expect((cut.end - cut.start + 360) % 360).toBeLessThan(20); - } + const cliff = obj(201000, 2, [[-10, 1], [10, 1]]); + expect(circleCuts([path, cliff], 0, 0)).toEqual([]); }); - it("cuts the rim stretch inside a building and merges overlaps", () => { - // Building covering everything west of x = 0: the west half of the - // rim is buried. + it("does not slit area objects", () => { const building = obj(521000, 3, [[-20, -20], [0, -20], [0, 20], [-20, 20]]); - const cuts = circleCuts([building], 0, 0); - expect(cuts).toHaveLength(1); - expect(gapAt(cuts, 270)).toBe(true); // due west - expect(gapAt(cuts, 90)).toBe(false); // due east untouched - // A boulder inside the buried stretch merges into the same gap. - const boulder = obj(204000, 1, [[-CIRCLE_RADIUS_MM, 0]]); - expect(circleCuts([building, boulder], 0, 0)).toHaveLength(1); - }); - - it("keeps the circle whole when cuts would erase almost all of it", () => { - // Circle entirely inside a building. - const building = obj(521000, 3, [[-20, -20], [20, -20], [20, 20], [-20, 20]]); expect(circleCuts([building], 0, 0)).toEqual([]); }); @@ -136,29 +111,24 @@ describe("legGaps", () => { expect((gaps[0].to - gaps[0].from) * 40).toBeLessThan(1); }); - it("excludes knolls from leg gaps", () => { + it("includes knolls in leg gaps", () => { const knoll = obj(109000, 1, [[20, 0]]); - expect(legGaps(A, B, [knoll])).toEqual([]); + const gaps = legGaps(A, B, [knoll]); + expect(gaps).toHaveLength(1); + expect(gaps[0].from).toBeLessThan(0.5); + expect(gaps[0].to).toBeGreaterThan(0.5); }); - it("gaps a black line crossing, wider when oblique", () => { + it("does not gap long line objects", () => { const perpendicular = obj(505000, 2, [[10, -5], [10, 5]]); const oblique = obj(505000, 2, [[25, -1], [35, 1]]); - const gaps = legGaps(A, B, [perpendicular, oblique]); - expect(gaps).toHaveLength(2); - const wPerp = gaps[0].to - gaps[0].from; - const wObl = gaps[1].to - gaps[1].from; - expect(gaps[0].from).toBeLessThan(10 / 40); - expect(gaps[0].to).toBeGreaterThan(10 / 40); - expect(wObl).toBeGreaterThan(wPerp); + expect(legGaps(A, B, [perpendicular, oblique])).toEqual([]); }); - it("gaps the stretch of leg inside a building", () => { + it("does not gap area objects", () => { const building = obj(521000, 3, [[15, -3], [25, -3], [25, 3], [15, 3]]); - const gaps = legGaps(A, B, [building]); - expect(gaps).toHaveLength(1); - expect(gaps[0].from).toBeCloseTo(15 / 40, 1); - expect(gaps[0].to).toBeCloseTo(25 / 40, 1); + const giantBoulder = obj(206000, 3, [[15, -3], [25, -3], [25, 3], [15, 3]]); + expect(legGaps(A, B, [building, giantBoulder])).toEqual([]); }); it("preserves the leg ends near the circles", () => { @@ -179,9 +149,6 @@ describe("legGaps", () => { const far2 = obj(204000, 1, [[22, 0]]); expect(legGaps(A, B, [far1, far2])).toHaveLength(2); - // A building covering nearly the whole leg trips the sanity cap. - const giant = obj(521000, 3, [[1, -5], [39, -5], [39, 5], [1, 5]]); - expect(legGaps(A, B, [giant])).toEqual([]); }); it("skips short legs entirely", () => { @@ -193,7 +160,7 @@ describe("legGaps", () => { describe("decorateOverprintCuts", () => { it("adds cuts to control circles and gaps to legs, leaving start/finish alone", () => { const boulderOnRim = obj(204000, 1, [[10 + CIRCLE_RADIUS_MM, 0]]); - const pathAcrossLeg = obj(505000, 2, [[25, -5], [25, 5]]); + const knollOnLeg = obj(109000, 1, [[25, 0]]); const geometry = { features: [ { @@ -214,7 +181,7 @@ describe("decorateOverprintCuts", () => { }, ], }; - decorateOverprintCuts(geometry, [boulderOnRim, pathAcrossLeg]); + decorateOverprintCuts(geometry, [boulderOnRim, knollOnLeg]); expect(geometry.features[0].properties).not.toHaveProperty("cuts"); expect(geometry.features[1].properties?.cuts).toHaveLength(1); diff --git a/packages/api/src/backup.ts b/packages/api/src/backup.ts index 8f31bd5..eff3112 100644 --- a/packages/api/src/backup.ts +++ b/packages/api/src/backup.ts @@ -144,7 +144,6 @@ export function spawnPgDump(eventId: bigint): PgDumpProcess { "event_log", "map_files", "rendered_maps", - "map_tiles", "tracks", "routes", "event_seqs", diff --git a/packages/api/src/course-geometry.ts b/packages/api/src/course-geometry.ts index 44c73d6..50419ee 100644 --- a/packages/api/src/course-geometry.ts +++ b/packages/api/src/course-geometry.ts @@ -21,6 +21,9 @@ import { Prisma as PrismaNs } from "./generated/prisma/client.js"; import { loadEventCrs } from "./event-crs.js"; import { loadEventMapObjects } from "./event-map-objects.js"; import { decorateOverprintCuts } from "./overprint-cuts.js"; +/** Increment when stored automatic cut/gap semantics change. */ +export const OVERPRINT_CUTS_VERSION = 2; + /** One entry of a course's rendered sequence (start + controls + finish). */ export interface GeometrySeqControl { @@ -132,10 +135,17 @@ export async function rebuildCourseGeometry( const crs = await loadEventCrs(db, eventId); const mapScale = crs?.scale ?? null; - // Base-map objects for automatic overprint cuts (circle slits over - // black features / knolls, leg gaps over black features). Cached per - // event, null when there is no map. - const mapObjects = await loadEventMapObjects(db, eventId); + // Base-map objects for automatic overprint cuts around compact knoll / + // rock point features. Cached per event, null when there is no map — + // and skipped when the event has auto overprint cuts turned off. + const eventRow = await db.event.findUnique({ + where: { id: eventId }, + select: { autoOverprintCuts: true }, + }); + const mapObjects = + eventRow?.autoOverprintCuts === false + ? null + : await loadEventMapObjects(db, eventId); // Event-level start/finish controls, shared across all rebuilt courses. const startFinish = await db.control.findMany({ @@ -237,3 +247,33 @@ export async function rebuildCourseGeometry( await db.course.update({ where: { id: courseUuid }, data }); } } + +/** + * Lazily migrate editor-authored geometry after an overprint-cut algorithm + * change. Imported OCD/XML geometry is deliberately untouched. + */ +export async function ensureOverprintCutsCurrent( + db: Db, + eventId: bigint, +): Promise { + const event = await db.event.findUnique({ + where: { id: eventId }, + select: { overprintCutsVersion: true }, + }); + if (!event || event.overprintCutsVersion >= OVERPRINT_CUTS_VERSION) return; + + const courses = await db.course.findMany({ + where: { eventId, removed: false, geometrySource: "editor" }, + select: { id: true }, + }); + await rebuildCourseGeometry( + db, + eventId, + courses.map((course) => course.id), + { updateLength: false }, + ); + await db.event.update({ + where: { id: eventId }, + data: { overprintCutsVersion: OVERPRINT_CUTS_VERSION }, + }); +} diff --git a/packages/api/src/course-maps/map-page-svg.ts b/packages/api/src/course-maps/map-page-svg.ts index 90eb1d8..3944c1a 100644 --- a/packages/api/src/course-maps/map-page-svg.ts +++ b/packages/api/src/course-maps/map-page-svg.ts @@ -31,7 +31,13 @@ export interface BaseMapSvg { export interface ComposeMapPageOptions { document: CourseMapDocument; window: MapWindow; + /** Full opaque base map. */ baseMap: BaseMapSvg; + /** + * Transparent ink layer (black/brown/blue 100% lines and points) drawn + * above lower purple. Null when the colour stack found nothing above. + */ + inkMap?: BaseMapSvg | null; controls: CourseOverlayControl[]; legs: CourseOverlayLeg[]; descriptionRows: DescriptionRow[]; @@ -72,6 +78,7 @@ export function renderBaseMapWindow( baseMap: BaseMapSvg, window: MapWindow, frame: MapRect, + options: { dataLayer?: string; forceTransparentFill?: boolean } = {}, ): string { const rotation = windowRotationDeg(window); const bbox = windowBoundingBox(window); @@ -91,7 +98,13 @@ export function renderBaseMapWindow( width: bbox.width * scale, height: bbox.height * scale, }; - const nested = ` + const fill = options.forceTransparentFill + ? "transparent" + : svgRootFill(baseMap.svg); + const layerAttr = options.dataLayer + ? ` data-map-layer="${options.dataLayer}"` + : ""; + const nested = ` ${svgInner(baseMap.svg)} `; if (rotation === 0) return nested; @@ -154,7 +167,15 @@ export function composeMapPageSvg(options: ComposeMapPageOptions): string { symbolResolver: (key) => IOF_SYMBOLS[key], }) : ""; + const ink = options.inkMap + ? renderBaseMapWindow(options.inkMap, window, frame, { + dataLayer: "map-ink", + forceTransparentFill: true, + }) + : ""; + // IOF digital stack: full map → lower purple → ink → whiteouts → + // upper purple → descriptions → foreground layout objects. return ` @@ -162,12 +183,16 @@ export function composeMapPageSvg(options: ComposeMapPageOptions): string { - ${renderBaseMapWindow(options.baseMap, window, frame)} + ${renderBaseMapWindow(options.baseMap, window, frame, { dataLayer: "map-full" })} + ${course.lower} + ${ink} + + ${renderMapObjectsSvg({ ...objectOpts, objects: mapWhiteouts })} ${renderMapObjectsSvg({ ...objectOpts, objects: pageWhiteouts })} - ${course} + ${course.upper} ${descriptions} diff --git a/packages/api/src/course-maps/map-source.ts b/packages/api/src/course-maps/map-source.ts index 145e1ba..f5dd6a8 100644 --- a/packages/api/src/course-maps/map-source.ts +++ b/packages/api/src/course-maps/map-source.ts @@ -1,10 +1,18 @@ import { JSDOM } from "jsdom"; import { windowBoundingBox, type MapWindow } from "@oxygen/shared"; -import { parseViewBox, type ViewBox } from "../map-window.js"; +import { parseViewBox } from "../map-window.js"; import { probeMeridianLines } from "../map-north.js"; +import { + applyIofColorStack, + filterNorthLineObjects, + inkToColor, + type StackOcadFile, +} from "../map-color-stack.js"; +import { ensureEventMapRenderKey } from "../map-render-cache.js"; import type { BaseMapSvg } from "./map-page-svg.js"; import type { PrismaClient } from "../generated/prisma/client.js"; import { LruCache } from "./lru-cache.js"; +import type { ColorProfile, ColorStackOverrides } from "@oxygen/shared"; type MapFileReader = Pick; @@ -13,15 +21,22 @@ interface RawObject { coordinates?: Array>; } -interface ParsedOcad { +interface ParsedOcad extends StackOcadFile { objects: RawObject[]; getBounds(): number[]; getCrs(): { scale?: number }; } +export interface BaseMapLayers { + /** Full opaque composite (all colours). */ + full: BaseMapSvg; + /** Transparent ink layer (above lower purple), or null when empty. */ + ink: BaseMapSvg | null; +} + interface CachedMap { id: bigint; - uploadedAtMs: number; + renderKey: string; file: ParsedOcad; scale: number | null; /** @@ -34,11 +49,14 @@ interface CachedMap { * by exactly this tilt. */ meridianTiltDeg: number; + /** Ink-layer `toColor` after IOF stack rewrite, or null when empty. */ + inkTo: number | null; + northLinesBelow: boolean; } const cache = new Map(); const MAX_CACHE = 3; -const svgCache = new LruCache<{ baseMap: BaseMapSvg; scale: number }>(8); +const svgCache = new LruCache<{ layers: BaseMapLayers; scale: number }>(8); const SVG_NS = "http://www.w3.org/2000/svg"; const DEFAULT_PATTERN_INFLATION = 8; @@ -101,28 +119,22 @@ export function inflateUserSpacePatterns( } async function parsedMap(db: MapFileReader, eventId: bigint): Promise { + const stackMeta = await ensureEventMapRenderKey(db, eventId); + if (!stackMeta) throw new Error("No base map uploaded"); + + const existing = cache.get(stackMeta.renderKey); + if (existing && existing.id === stackMeta.mapFileId) { + cache.delete(stackMeta.renderKey); + cache.set(stackMeta.renderKey, existing); + return existing; + } + const row = await db.mapFile.findFirst({ where: { eventId }, orderBy: { uploadedAt: "desc" }, - select: { - id: true, - fileData: true, - scale: true, - uploadedAt: true, - }, + select: { id: true, fileData: true, scale: true }, }); if (!row) throw new Error("No base map uploaded"); - const key = eventId.toString(); - const existing = cache.get(key); - if ( - existing && - existing.id === row.id && - existing.uploadedAtMs === row.uploadedAt.getTime() - ) { - cache.delete(key); - cache.set(key, existing); - return existing; - } const ocadModule = await import("ocad2geojson"); const readOcad = (ocadModule as Record).readOcad as ( @@ -132,17 +144,29 @@ async function parsedMap(db: MapFileReader, eventId: bigint): Promise const file = await readOcad(Buffer.from(row.fileData), { quietWarnings: true, }); - const value = { + const stack = applyIofColorStack(file, { + profile: stackMeta.colorProfile as ColorProfile, + overrides: stackMeta.colorOverrides as ColorStackOverrides, + scale: stackMeta.scale, + }); + if (stack.warnings.length > 0) { + console.warn( + `[map-color-stack] event ${eventId}: ${stack.warnings.join("; ")}`, + ); + } + const value: CachedMap = { id: row.id, - uploadedAtMs: row.uploadedAt.getTime(), + renderKey: stackMeta.renderKey, file, scale: row.scale ?? file.getCrs().scale ?? null, meridianTiltDeg: probeMeridianLines( file.objects as Parameters[0], )?.medianTiltDeg ?? 0, + inkTo: inkToColor(stack), + northLinesBelow: stackMeta.northLinesBelow, }; - cache.set(key, value); + cache.set(stackMeta.renderKey, value); while (cache.size > MAX_CACHE) { cache.delete(cache.keys().next().value!); } @@ -196,15 +220,13 @@ export async function loadBaseMapSvg( db: MapFileReader, eventId: bigint, window: MapWindow, -): Promise<{ baseMap: BaseMapSvg; scale: number }> { +): Promise<{ layers: BaseMapLayers; scale: number }> { const source = await parsedMap(db, eventId); if (!source.scale || source.scale <= 0) { throw new Error("Base map has no usable scale"); } const cacheKey = [ - eventId.toString(), - source.id.toString(), - source.uploadedAtMs, + source.renderKey, roundedWindowKey(window), ].join(":"); const cached = svgCache.get(cacheKey); @@ -217,21 +239,45 @@ export async function loadBaseMapSvg( const document = new JSDOM( "", ).window.document; - const svg = inflateUserSpacePatterns( + const objects = objectsInWindow(source.file.objects, window, 20); + const ocadBounds = source.file.getBounds(); + + const fullSvg = inflateUserSpacePatterns( ocadToSvg(source.file, { document, generateSymbolElements: true, exportHidden: false, - objects: objectsInWindow(source.file.objects, window, 20), + objects, }).outerHTML, ); - const rootViewBox: ViewBox | null = parseViewBox(svg); - if (!rootViewBox) throw new Error("Base map SVG has no viewBox"); + const fullViewBox = parseViewBox(fullSvg); + if (!fullViewBox) throw new Error("Base map SVG has no viewBox"); + + let ink: BaseMapSvg | null = null; + if (source.inkTo != null) { + const inkObjects = source.northLinesBelow + ? filterNorthLineObjects(objects) ?? [] + : objects; + const inkSvg = inflateUserSpacePatterns( + ocadToSvg(source.file, { + document, + generateSymbolElements: true, + exportHidden: false, + objects: inkObjects, + toColor: source.inkTo, + // Transparent fill so the ink layer does not paint a white sheet. + fill: "transparent", + }).outerHTML, + ); + const inkViewBox = parseViewBox(inkSvg); + if (!inkViewBox) throw new Error("Ink layer SVG has no viewBox"); + ink = { svg: inkSvg, rootViewBox: inkViewBox, ocadBounds }; + } + const result = { - baseMap: { - svg, - rootViewBox, - ocadBounds: source.file.getBounds(), + layers: { + full: { svg: fullSvg, rootViewBox: fullViewBox, ocadBounds }, + ink, }, scale: source.scale, }; @@ -261,7 +307,7 @@ export async function getBaseMapInfo( return { scale: source.scale, meridianTiltDeg: source.meridianTiltDeg, - version: `${source.id.toString()}-${source.uploadedAtMs}`, + version: source.renderKey, }; } diff --git a/packages/api/src/course-maps/routes.ts b/packages/api/src/course-maps/routes.ts index a7920bc..527e004 100644 --- a/packages/api/src/course-maps/routes.ts +++ b/packages/api/src/course-maps/routes.ts @@ -209,11 +209,12 @@ async function renderPage( event: { name: event.name, date: event.date }, mapName: row.name, }); - const { baseMap } = await loadBaseMapSvg(db, event.id, layout.window); + const { layers } = await loadBaseMapSvg(db, event.id, layout.window); return composeMapPageSvg({ document: layout.document, window: layout.window, - baseMap, + baseMap: layers.full, + inkMap: layers.ink, controls: layout.controls, legs: layout.legs, descriptionRows: layout.descriptionRows, @@ -307,6 +308,7 @@ export function registerCourseMapRoutes( printScale?: string; dpi?: string; rot?: string; + layer?: string; }; }>("/api/maps/:nameId/window.png", async (req, reply) => { const { nameId } = req.params; @@ -336,6 +338,11 @@ export function registerCourseMapRoutes( if (!Number.isFinite(rotation) || Math.abs(rotation) > 180) { throw new Error("rot must be a rotation between -180 and 180 degrees"); } + const layerRaw = (req.query.layer ?? "full").toLowerCase(); + if (layerRaw !== "full" && layerRaw !== "ink") { + throw new Error('layer must be "full" or "ink"'); + } + const layer = layerRaw as "full" | "ink"; const widthPx = Math.max(1, Math.round((widthMm / 25.4) * dpi)); const heightPx = Math.max(1, Math.round((heightMm / 25.4) * dpi)); if (widthPx > MAX_RENDER_PX || heightPx > MAX_RENDER_PX) { @@ -354,6 +361,7 @@ export function registerCourseMapRoutes( printScale.toFixed(2), dpi.toFixed(2), rotation.toFixed(2), + layer, ].join(":"); const cached = windowPngCache.get(cacheKey); if (cached) { @@ -379,12 +387,41 @@ export function registerCourseMapRoutes( printScale, rotation, ); - const { baseMap } = await loadBaseMapSvg(db, event.id, window); - const svg = `${renderBaseMapWindow(baseMap, window, frame)}`; + const { layers } = await loadBaseMapSvg(db, event.id, window); + const source = + layer === "ink" + ? layers.ink + : layers.full; + if (!source) { + // Empty ink layer: 1×1 transparent PNG. + const { default: sharp } = await import("sharp"); + const body = await sharp({ + create: { + width: 1, + height: 1, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, + }) + .png() + .toBuffer(); + const etag = `"${createHash("sha256").update(body).digest("base64url")}"`; + windowPngCache.set(cacheKey, { body, etag }); + return reply + .header("Content-Type", "image/png") + .header("Cache-Control", "private, max-age=3600") + .header("ETag", etag) + .header("X-Cache", "miss") + .send(body); + } + const svg = `${renderBaseMapWindow(source, window, frame, { + forceTransparentFill: layer === "ink", + dataLayer: layer === "ink" ? "map-ink" : "map-full", + })}`; const { renderAsync } = await import("@resvg/resvg-js"); const rendered = await renderAsync(svg, { fitTo: { mode: "width", value: widthPx }, - background: "white", + ...(layer === "ink" ? {} : { background: "white" }), }); const body = Buffer.from(rendered.asPng()); const etag = `"${createHash("sha256").update(body).digest("base64url")}"`; diff --git a/packages/api/src/event-map.ts b/packages/api/src/event-map.ts index df27f31..790d8f2 100644 --- a/packages/api/src/event-map.ts +++ b/packages/api/src/event-map.ts @@ -7,7 +7,10 @@ import type { PrismaClient, Prisma } from "./generated/prisma/client.js"; import { fireMapUpload } from "./db.js"; -import { rebuildCourseGeometry } from "./course-geometry.js"; +import { + OVERPRINT_CUTS_VERSION, + rebuildCourseGeometry, +} from "./course-geometry.js"; import { emitCourseUpserted } from "./referenceJournal.js"; import { ocadBoundsToWgs84, @@ -27,6 +30,14 @@ import { type OcadNorthSource, } from "./map-north.js"; import { loadEventCrs } from "./event-crs.js"; +import { computeRenderKey, hashMapFileData } from "./map-render-key.js"; +import { + gcOrphanTiles, + refreshMapFileRenderKey, + refreshClubMapRenderKey, +} from "./map-render-cache.js"; +import type { ColorProfile, ColorStackOverrides } from "@oxygen/shared"; +import { colorProfileSchema, colorStackOverridesSchema } from "@oxygen/shared"; type Db = PrismaClient | Prisma.TransactionClient; @@ -287,7 +298,7 @@ export async function synchronizePositionedControlCoordinates( return updates.length; } -/** Replace the event map, drop tile caches, rebuild editor course geometry. */ +/** Replace the event map, drop rendered-map caches, rebuild editor course geometry. */ export async function applyEventMap( db: Db, eventId: bigint, @@ -297,16 +308,40 @@ export async function applyEventMap( fromClubLibrary?: boolean; /** Club-library copy: carry the library row's manual correction along. */ rotationCorrection?: number; + colorProfile?: ColorProfile; + colorOverrides?: ColorStackOverrides; + northLinesBelow?: boolean; + /** When copying from club library, reuse its content hash. */ + fileHash?: string; } = {}, ): Promise<{ fileName: string; size: number; rotationCorrection: number; meridianStalenessDeg: number | null; + renderKey: string; }> { const resolved = await resolveMapNorth(buffer, { forcedCorrection: opts.rotationCorrection, }); + const colorProfile = (() => { + const p = colorProfileSchema.safeParse(opts.colorProfile ?? "auto"); + return p.success ? p.data : "auto"; + })(); + const colorOverrides = (() => { + const p = colorStackOverridesSchema.safeParse(opts.colorOverrides ?? {}); + return p.success ? p.data : {}; + })(); + const northLinesBelow = opts.northLinesBelow !== false; + const fileHash = opts.fileHash ?? hashMapFileData(buffer); + const renderKey = computeRenderKey({ + fileHash, + rotationCorrection: resolved.rotationCorrection, + colorProfile, + colorOverrides, + northLinesBelow, + }); + await db.mapFile.deleteMany({ where: { eventId } }); await db.mapFile.create({ data: { @@ -324,10 +359,15 @@ export async function applyEventMap( rotationCorrection: resolved.rotationCorrection, northDetection: detectionJson(resolved.northDetection), fromClubLibrary: opts.fromClubLibrary === true, + colorProfile, + colorOverrides: colorOverrides as unknown as Prisma.InputJsonValue, + northLinesBelow, + fileHash, + renderKey, }, }); - await db.mapTile.deleteMany({ where: { eventId } }); await db.renderedMap.deleteMany({ where: { eventId } }); + await gcOrphanTiles(db); fireMapUpload(eventId); await synchronizePositionedControlCoordinates(db, eventId); @@ -342,12 +382,17 @@ export async function applyEventMap( await emitCourseUpserted(db, eventId, id); } } + await db.event.update({ + where: { id: eventId }, + data: { overprintCutsVersion: OVERPRINT_CUTS_VERSION }, + }); return { fileName, size: buffer.length, rotationCorrection: resolved.rotationCorrection, meridianStalenessDeg: resolved.meridianStalenessDeg, + renderKey, }; } @@ -385,12 +430,13 @@ export async function applyMapRotationCorrection( calibration: metadata.calibration ? (metadata.calibration as unknown as Prisma.InputJsonValue) : undefined, - // Bump stamp so clients and the tile SVG cache invalidate. + // Bump stamp so clients invalidate; render_key is refreshed below. uploadedAt: new Date(), }, }); - await db.mapTile.deleteMany({ where: { eventId } }); + await refreshMapFileRenderKey(db, row.id); await db.renderedMap.deleteMany({ where: { eventId } }); + await gcOrphanTiles(db); fireMapUpload(eventId); await synchronizePositionedControlCoordinates(db, eventId); @@ -405,6 +451,10 @@ export async function applyMapRotationCorrection( await emitCourseUpserted(db, eventId, id); } } + await db.event.update({ + where: { id: eventId }, + data: { overprintCutsVersion: OVERPRINT_CUTS_VERSION }, + }); return { ...metadata, rotationCorrection: rotationCorrectionDeg }; } @@ -440,5 +490,7 @@ export async function applyClubMapRotationCorrection( northOffset: metadata.northOffset, }, }); + await refreshClubMapRenderKey(db, row.id); + await gcOrphanTiles(db); return { ...metadata, rotationCorrection: rotationCorrectionDeg }; } diff --git a/packages/api/src/map-color-stack.ts b/packages/api/src/map-color-stack.ts new file mode 100644 index 0000000..e936eed --- /dev/null +++ b/packages/api/src/map-color-stack.ts @@ -0,0 +1,441 @@ +/** + * IOF colour-stack classification for course overprinting. + * + * ISOM / ISSprOM / ISSkiOM / ISMTBOM place "lower purple" (course + * circles, legs, start, finish) under a profile-specific set of map + * colours so those features stay sharp through the purple. Digital + * printers simulate that by colour order, not blend modes — see IOF + * Printing and Colour Definitions rev. 4, chapter 7. + * + * This module classifies each OCAD colour as "above" or "below" that + * lower-purple slot from its CMYK values, which symbol geometry types + * use it, and the active profile, then rewrites `renderOrder` so + * `ocadToSvg`'s `fromColor` / `toColor` filters can emit an ink layer + * (above) and a full composite. Relative order inside each group is + * preserved. + */ + +import type { + ColorProfile, + ColorStackOverrides, + ResolvedColorProfile, +} from "@oxygen/shared"; + +export type { ColorProfile, ColorStackOverrides, ResolvedColorProfile }; + +export interface StackColor { + number: number; + cmyk: number[]; + name?: string; + renderOrder: number; +} + +export interface StackSymbolElement { + color?: number; +} + +export interface StackSymbol { + type?: number; + lineColor?: number; + frColor?: number; + fillColor?: number; + hatchColor?: number; + fontColor?: number; + elements?: StackSymbolElement[]; + doubleLine?: { + dblFillColor?: number; + dblLeftColor?: number; + dblRightColor?: number; + }; +} + +export interface StackOcadObject { + sym?: number; +} + +export interface StackOcadFile { + colors: Array; + symbols: StackSymbol[]; + objects?: StackOcadObject[]; +} + +export interface ColorUsage { + line: boolean; + point: boolean; + area: boolean; + text: boolean; +} + +export type ColorStackResolvedBy = + | "explicit" + | "file-colour" + | "scale" + | "default"; + +export interface ColorStackResult { + /** First renderOrder of the below group (= ink layer `toColor + 1`). */ + boundary: number; + /** Colour numbers classified above lower purple (low renderOrder). */ + above: number[]; + /** Colour numbers classified below lower purple (high renderOrder). */ + below: number[]; + warnings: string[]; + /** Rule set actually applied (never `auto`). */ + resolvedProfile: ResolvedColorProfile; + /** How `resolvedProfile` / the boundary was chosen. */ + resolvedBy: ColorStackResolvedBy; +} + +export interface ApplyIofColorStackOpts { + profile?: ColorProfile; + overrides?: ColorStackOverrides; + /** Map scale denominator (e.g. 15000). Used when profile is `auto`. */ + scale?: number | null; +} + +type UsageMap = Map; + +/** ISOM 601.x magnetic north lines — filtered from the ink layer when + * `northLinesBelow` is on (colour shared with streams). */ +export const MERIDIAN_SYMBOL_BASE = 601; + +const LOWER_PURPLE_NAME = /lower\s*purple|undre\s*lila/i; + +function emptyUsage(): ColorUsage { + return { line: false, point: false, area: false, text: false }; +} + +function mark( + usage: UsageMap, + colorNumber: number | undefined | null, + kind: keyof ColorUsage, +): void { + if (colorNumber == null || !Number.isFinite(colorNumber) || colorNumber < 0) { + return; + } + let entry = usage.get(colorNumber); + if (!entry) { + entry = emptyUsage(); + usage.set(colorNumber, entry); + } + entry[kind] = true; +} + +function collectUsage(symbols: StackSymbol[]): UsageMap { + const usage: UsageMap = new Map(); + for (const symbol of symbols) { + const type = symbol.type; + if (type === 1) { + for (const element of symbol.elements ?? []) { + mark(usage, element.color, "point"); + } + } else if (type === 2) { + mark(usage, symbol.lineColor, "line"); + mark(usage, symbol.frColor, "line"); + const dbl = symbol.doubleLine; + if (dbl) { + mark(usage, dbl.dblFillColor, "line"); + mark(usage, dbl.dblLeftColor, "line"); + mark(usage, dbl.dblRightColor, "line"); + } + for (const element of symbol.elements ?? []) { + mark(usage, element.color, "line"); + } + } else if (type === 3) { + mark(usage, symbol.fillColor, "area"); + mark(usage, symbol.hatchColor, "area"); + for (const element of symbol.elements ?? []) { + mark(usage, element.color, "area"); + } + } else if (type === 4) { + mark(usage, symbol.fontColor, "text"); + } + } + return usage; +} + +function cmyk(color: StackColor): [number, number, number, number] { + const [c = 0, m = 0, y = 0, k = 0] = color.cmyk ?? []; + return [Number(c) || 0, Number(m) || 0, Number(y) || 0, Number(k) || 0]; +} + +function isBlack100(c: number, m: number, y: number, k: number): boolean { + return k >= 90 && c <= 15 && m <= 15 && y <= 15; +} + +function isBlue100(c: number, m: number, y: number, k: number): boolean { + return c >= 90 && m <= 20 && y <= 10 && k <= 10; +} + +function isBrown100(c: number, m: number, y: number, k: number): boolean { + // Covers ISOM 0/56/100/18 and ISSprOM 25/75/100/0. + return y >= 90 && m >= 45 && c <= 40 && k <= 30; +} + +function isWhite(c: number, m: number, y: number, k: number): boolean { + return c <= 1 && m <= 1 && y <= 1 && k <= 1; +} + +function isGreen100(c: number, m: number, y: number, k: number): boolean { + return c >= 70 && y >= 85 && m <= 20 && k <= 40; +} + +/** Ski-O track green 91/0/83/0 (and nearby). */ +function isSkiOGreen(c: number, m: number, y: number, k: number): boolean { + return c >= 80 && y >= 70 && m <= 15 && k <= 15; +} + +function isPurple100(c: number, m: number, y: number, k: number): boolean { + // Full course purple 35/85/0/0 — never "above". + return m >= 70 && c >= 20 && c <= 50 && y <= 15 && k <= 10; +} + +/** Purple 50 % area (18/43/0/0) — above lower purple in ISSprOM / ISMTBOM. */ +function isPurple50(c: number, m: number, y: number, k: number): boolean { + return ( + c >= 10 && + c <= 30 && + m >= 30 && + m <= 55 && + y <= 15 && + k <= 10 && + !isPurple100(c, m, y, k) + ); +} + +/** + * Decide whether a colour belongs above the lower-purple slot under the + * given resolved profile. + */ +export function isAboveLowerPurple( + color: StackColor, + usage: ColorUsage | undefined, + profile: ResolvedColorProfile = "isom", +): boolean { + const [c, m, y, k] = cmyk(color); + const u = usage ?? emptyUsage(); + const usedByLinePointText = u.line || u.point || u.text; + const usedByLinePoint = u.line || u.point; + const usedOnlyByPoint = u.point && !u.line && !u.area && !u.text; + + // Full course purple is the slot we insert — never ink. + if (isPurple100(c, m, y, k)) return false; + + if (isBlack100(c, m, y, k) && usedByLinePointText) return true; + if (isWhite(c, m, y, k) && usedByLinePoint) return true; + + if (profile === "isom") { + if (isBlue100(c, m, y, k) && usedByLinePoint) return true; + if (isBrown100(c, m, y, k) && usedByLinePoint) return true; + if (isGreen100(c, m, y, k) && usedOnlyByPoint) return true; + return false; + } + + if (profile === "issprom") { + // Brown and blue 100 % sit BELOW lower purple in ISSprOM. + if (isGreen100(c, m, y, k) && usedOnlyByPoint) return true; + if (isPurple50(c, m, y, k) && u.area) return true; + return false; + } + + if (profile === "isskiom") { + if (isBlue100(c, m, y, k) && usedByLinePoint) return true; + // Ski-O green track colour (line) and green 100 % points. + if ((isGreen100(c, m, y, k) || isSkiOGreen(c, m, y, k)) && usedByLinePoint) { + return true; + } + return false; + } + + // ismtbom + if (isBlue100(c, m, y, k) && usedByLinePoint) return true; + if (isGreen100(c, m, y, k) && usedOnlyByPoint) return true; + if (isPurple50(c, m, y, k) && u.area) return true; + return false; +} + +function definedColors(file: StackOcadFile): StackColor[] { + const out: StackColor[] = []; + for (const color of file.colors) { + if (color && Number.isFinite(color.number)) out.push(color); + } + // Preserve the file's original top-to-bottom order. + out.sort((a, b) => a.renderOrder - b.renderOrder); + return out; +} + +function findLowerPurpleColour(colors: StackColor[]): StackColor | null { + for (const color of colors) { + if (color.name && LOWER_PURPLE_NAME.test(color.name)) return color; + } + return null; +} + +/** + * Resolve `auto` → a concrete profile. When a "Lower purple" colour is + * present the caller should use file-order stacking instead; this helper + * still returns a profile for logging / UI. + */ +export function resolveColorProfile( + profile: ColorProfile, + scale: number | null | undefined, +): { resolvedProfile: ResolvedColorProfile; resolvedBy: ColorStackResolvedBy } { + if (profile !== "auto") { + return { resolvedProfile: profile, resolvedBy: "explicit" }; + } + if (scale != null && Number.isFinite(scale) && scale > 0 && scale <= 5000) { + return { resolvedProfile: "issprom", resolvedBy: "scale" }; + } + return { resolvedProfile: "isom", resolvedBy: scale != null ? "scale" : "default" }; +} + +function applyOverrides( + above: StackColor[], + below: StackColor[], + overrides: ColorStackOverrides | undefined, +): { above: StackColor[]; below: StackColor[] } { + if (!overrides) return { above, below }; + const forceAbove = new Set(overrides.above ?? []); + const forceBelow = new Set(overrides.below ?? []); + if (forceAbove.size === 0 && forceBelow.size === 0) return { above, below }; + + const byNumber = new Map(); + for (const c of above) byNumber.set(c.number, c); + for (const c of below) byNumber.set(c.number, c); + + const nextAbove: StackColor[] = []; + const nextBelow: StackColor[] = []; + const seen = new Set(); + + // Preserve relative order: walk original above then below. + for (const c of [...above, ...below]) { + if (seen.has(c.number)) continue; + seen.add(c.number); + if (forceBelow.has(c.number)) nextBelow.push(c); + else if (forceAbove.has(c.number)) nextAbove.push(c); + else if (above.includes(c)) nextAbove.push(c); + else nextBelow.push(c); + } + // Colours only mentioned in overrides that weren't in the file — ignore. + void byNumber; + return { above: nextAbove, below: nextBelow }; +} + +function rewriteOrders(above: StackColor[], below: StackColor[]): number { + let order = 0; + for (const color of above) { + color.renderOrder = order++; + } + const boundary = order; + for (const color of below) { + color.renderOrder = order++; + } + return boundary; +} + +/** + * Classify colours and rewrite `renderOrder` in place so indices + * `0..boundary-1` are the ink layer (above lower purple) and + * `boundary..n-1` are everything else. + * + * When nothing classifies as above, `boundary` is 0 and the ink layer + * is empty — callers degrade to a single composite without stacking. + */ +export function applyIofColorStack( + file: StackOcadFile, + opts: ApplyIofColorStackOpts = {}, +): ColorStackResult { + const profile = opts.profile ?? "auto"; + const usage = collectUsage(file.symbols ?? []); + const colors = definedColors(file); + const warnings: string[] = []; + + // Auto + named "Lower purple" colour → use the file's own order as the + // boundary (everything currently above that colour stays above). + if (profile === "auto") { + const lower = findLowerPurpleColour(colors); + if (lower) { + const above: StackColor[] = []; + const below: StackColor[] = []; + for (const color of colors) { + if (color.renderOrder < lower.renderOrder) above.push(color); + else below.push(color); + } + const overridden = applyOverrides(above, below, opts.overrides); + const boundary = rewriteOrders(overridden.above, overridden.below); + // Still report a scale-based profile for UI context. + const { resolvedProfile } = resolveColorProfile("auto", opts.scale); + return { + boundary, + above: overridden.above.map((c) => c.number), + below: overridden.below.map((c) => c.number), + warnings, + resolvedProfile, + resolvedBy: "file-colour", + }; + } + } + + const { resolvedProfile, resolvedBy } = resolveColorProfile(profile, opts.scale); + + let above: StackColor[] = []; + let below: StackColor[] = []; + for (const color of colors) { + if (isAboveLowerPurple(color, usage.get(color.number), resolvedProfile)) { + above.push(color); + } else { + below.push(color); + } + } + + ({ above, below } = applyOverrides(above, below, opts.overrides)); + + if (above.length === 0) { + warnings.push("no black/brown/blue 100% line/point colour found for ink layer"); + } else { + const hasBlack = above.some((color) => { + const [c, m, y, k] = cmyk(color); + return isBlack100(c, m, y, k); + }); + if (!hasBlack) { + warnings.push("no black 100% line/point colour found above lower purple"); + } + } + + const boundary = rewriteOrders(above, below); + + return { + boundary, + above: above.map((color) => color.number), + below: below.map((color) => color.number), + warnings, + resolvedProfile, + resolvedBy, + }; +} + +/** Ink-layer `toColor` for `ocadToSvg`, or `null` when the ink layer is empty. */ +export function inkToColor(result: ColorStackResult): number | null { + return result.boundary > 0 ? result.boundary - 1 : null; +} + +/** + * Drop magnetic-north line objects (ISOM 601.x) from an object list so + * they stay below the course purple in the ink layer. The composite + * render keeps them. + */ +export function filterNorthLineObjects( + objects: T[] | undefined | null, +): T[] | undefined { + if (!objects) return undefined; + return objects.filter((o) => { + const sym = o.sym; + if (sym == null || !Number.isFinite(sym)) return true; + return Math.floor(sym / 1000) !== MERIDIAN_SYMBOL_BASE; + }); +} + +export function isMeridianSymbol(sym: number | undefined | null): boolean { + if (sym == null || !Number.isFinite(sym)) return false; + return Math.floor(sym / 1000) === MERIDIAN_SYMBOL_BASE; +} diff --git a/packages/api/src/map-north.ts b/packages/api/src/map-north.ts index 0800935..deb7931 100644 --- a/packages/api/src/map-north.ts +++ b/packages/api/src/map-north.ts @@ -296,6 +296,29 @@ export function displayNorthOffsetDeg( return paperNorthOffsetDeg + (meridian?.medianTiltDeg ?? 0); } +/** + * Rotation to apply to automatic circle slits when drawing in screen + * space. Cuts are authored in paper-mm compass bearings (0° = paper +Y); + * the viewer rotates the whole map by `-northOffset` which already + * folds in meridian tilt, so slits must use only the paper-to-true-north + * part — otherwise a 3° meridian tilt biases every slit clockwise. + * + * `northOffset` is the stored display value (`displayNorthOffsetDeg`); + * `meridianTiltDeg` is the in-paper tilt of ISOM 601.x lines (0 when the + * file has none). + */ +export function cutRotationDeg( + northOffset: number | null | undefined, + meridianTiltDeg: number | null | undefined, +): number | null { + if (northOffset == null || !Number.isFinite(northOffset)) return null; + const tilt = + meridianTiltDeg != null && Number.isFinite(meridianTiltDeg) + ? meridianTiltDeg + : 0; + return northOffset - tilt; +} + /** * Analyse a parsed OCAD file's north situation. Pure diagnostics: the * result never changes how the map is georeferenced. `asOf` defaults to diff --git a/packages/api/src/map-render-cache.ts b/packages/api/src/map-render-cache.ts new file mode 100644 index 0000000..15313b0 --- /dev/null +++ b/packages/api/src/map-render-cache.ts @@ -0,0 +1,251 @@ +/** + * Ensure a map_files / club_map_files row has file_hash + render_key. + * Used by tile serving, print SVG load, and mapMetadata so the key is + * filled lazily (same pattern as the north_detection backfill). + */ + +import type { ColorProfile, ColorStackOverrides } from "@oxygen/shared"; +import { colorProfileSchema, colorStackOverridesSchema } from "@oxygen/shared"; +import type { PrismaClient } from "./generated/prisma/client.js"; +import { computeRenderKey, hashMapFileData } from "./map-render-key.js"; + +type MapFileDb = { + mapFile: PrismaClient["mapFile"]; +}; + +type ClubMapDb = { + clubMapFile: PrismaClient["clubMapFile"]; +}; + +type GcDb = { + $executeRawUnsafe: PrismaClient["$executeRawUnsafe"]; +}; + +export interface MapStackSettings { + colorProfile: ColorProfile; + colorOverrides: ColorStackOverrides; + northLinesBelow: boolean; + rotationCorrection: number; + fileHash: string; + renderKey: string; + scale: number | null; +} + +function parseProfile(raw: string | null | undefined): ColorProfile { + const parsed = colorProfileSchema.safeParse(raw ?? "auto"); + return parsed.success ? parsed.data : "auto"; +} + +function parseOverrides(raw: unknown): ColorStackOverrides { + const parsed = colorStackOverridesSchema.safeParse(raw ?? {}); + return parsed.success ? parsed.data : {}; +} + +/** + * Read the event's current map stack settings, computing and persisting + * `file_hash` / `render_key` when missing. Returns null when the event + * has no map file. + */ +export async function ensureEventMapRenderKey( + db: MapFileDb, + eventId: bigint, +): Promise<(MapStackSettings & { mapFileId: bigint; uploadedAtMs: number; bounds: unknown }) | null> { + const row = await db.mapFile.findFirst({ + where: { eventId }, + orderBy: { id: "desc" }, + select: { + id: true, + uploadedAt: true, + bounds: true, + scale: true, + rotationCorrection: true, + colorProfile: true, + colorOverrides: true, + northLinesBelow: true, + fileHash: true, + renderKey: true, + fileData: true, + }, + }); + if (!row) return null; + + const colorProfile = parseProfile(row.colorProfile); + const colorOverrides = parseOverrides(row.colorOverrides); + const northLinesBelow = row.northLinesBelow; + const rotationCorrection = row.rotationCorrection; + + let fileHash = row.fileHash; + if (!fileHash) { + fileHash = hashMapFileData(Buffer.from(row.fileData)); + } + let renderKey = row.renderKey; + const expected = computeRenderKey({ + fileHash, + rotationCorrection, + colorProfile, + colorOverrides, + northLinesBelow, + }); + if (renderKey !== expected) { + renderKey = expected; + } + + if (row.fileHash !== fileHash || row.renderKey !== renderKey) { + await db.mapFile.update({ + where: { id: row.id }, + data: { fileHash, renderKey }, + }); + } + + return { + mapFileId: row.id, + uploadedAtMs: row.uploadedAt.getTime(), + bounds: row.bounds, + scale: row.scale, + colorProfile, + colorOverrides, + northLinesBelow, + rotationCorrection, + fileHash, + renderKey, + }; +} + +/** + * Same as `ensureEventMapRenderKey` for a club-library row. + */ +export async function ensureClubMapRenderKey( + db: ClubMapDb, + clubMapId: bigint, +): Promise { + const row = await db.clubMapFile.findUnique({ + where: { id: clubMapId }, + select: { + id: true, + scale: true, + rotationCorrection: true, + colorProfile: true, + colorOverrides: true, + northLinesBelow: true, + fileHash: true, + renderKey: true, + fileData: true, + }, + }); + if (!row) return null; + + const colorProfile = parseProfile(row.colorProfile); + const colorOverrides = parseOverrides(row.colorOverrides); + const northLinesBelow = row.northLinesBelow; + const rotationCorrection = row.rotationCorrection; + + let fileHash = row.fileHash; + if (!fileHash) { + fileHash = hashMapFileData(Buffer.from(row.fileData)); + } + const renderKey = computeRenderKey({ + fileHash, + rotationCorrection, + colorProfile, + colorOverrides, + northLinesBelow, + }); + + if (row.fileHash !== fileHash || row.renderKey !== renderKey) { + await db.clubMapFile.update({ + where: { id: row.id }, + data: { fileHash, renderKey }, + }); + } + + return { + scale: row.scale, + colorProfile, + colorOverrides, + northLinesBelow, + rotationCorrection, + fileHash, + renderKey, + }; +} + +/** + * Drop tile rows whose render_key is no longer referenced by any + * map_files or club_map_files row. Safe to call after upload, profile + * change, rotation change, or event delete. + */ +export async function gcOrphanTiles(db: GcDb): Promise { + // Prisma can't express NOT IN (UNION …) cleanly; raw SQL is fine — + // map_tiles is a pure cache. + const result = await db.$executeRawUnsafe(` + DELETE FROM oxygen.map_tiles + WHERE render_key IS NOT NULL + AND render_key NOT IN ( + SELECT render_key FROM oxygen.map_files WHERE render_key IS NOT NULL + UNION + SELECT render_key FROM oxygen.club_map_files WHERE render_key IS NOT NULL + ) + `); + return typeof result === "number" ? result : 0; +} + +/** Recompute and store render_key after a settings change on map_files. */ +export async function refreshMapFileRenderKey( + db: MapFileDb, + mapFileId: bigint, +): Promise { + const row = await db.mapFile.findUniqueOrThrow({ + where: { id: mapFileId }, + select: { + rotationCorrection: true, + colorProfile: true, + colorOverrides: true, + northLinesBelow: true, + fileHash: true, + fileData: true, + }, + }); + const fileHash = row.fileHash ?? hashMapFileData(Buffer.from(row.fileData)); + const renderKey = computeRenderKey({ + fileHash, + rotationCorrection: row.rotationCorrection, + colorProfile: parseProfile(row.colorProfile), + colorOverrides: parseOverrides(row.colorOverrides), + northLinesBelow: row.northLinesBelow, + }); + await db.mapFile.update({ + where: { id: mapFileId }, + data: { fileHash, renderKey }, + }); + return renderKey; +} + +export async function refreshClubMapRenderKey( + db: ClubMapDb, + clubMapId: bigint, +): Promise { + const row = await db.clubMapFile.findUniqueOrThrow({ + where: { id: clubMapId }, + select: { + rotationCorrection: true, + colorProfile: true, + colorOverrides: true, + northLinesBelow: true, + fileHash: true, + fileData: true, + }, + }); + const fileHash = row.fileHash ?? hashMapFileData(Buffer.from(row.fileData)); + const renderKey = computeRenderKey({ + fileHash, + rotationCorrection: row.rotationCorrection, + colorProfile: parseProfile(row.colorProfile), + colorOverrides: parseOverrides(row.colorOverrides), + northLinesBelow: row.northLinesBelow, + }); + await db.clubMapFile.update({ + where: { id: clubMapId }, + data: { fileHash, renderKey }, + }); + return renderKey; +} diff --git a/packages/api/src/map-render-key.ts b/packages/api/src/map-render-key.ts new file mode 100644 index 0000000..fcf3b5c --- /dev/null +++ b/packages/api/src/map-render-key.ts @@ -0,0 +1,54 @@ +/** + * Content-keyed map tile / print cache identity. + * + * Tiles are shared across events that use the same OCAD blob with the same + * colour-stack settings and rotation correction. `computeRenderKey` is the + * sole identity; callers store it on `map_files.render_key` / + * `club_map_files.render_key` and look up `map_tiles` by that key. + */ + +import { createHash } from "node:crypto"; +import type { ColorProfile, ColorStackOverrides } from "@oxygen/shared"; + +/** Must match `TILE_FORMAT` in map-tiles.ts / kiosk-key.ts. */ +export const RENDER_TILE_FORMAT = 2; + +export interface RenderKeyInput { + fileHash: string; + rotationCorrection: number; + colorProfile: ColorProfile; + colorOverrides?: ColorStackOverrides | null; + northLinesBelow: boolean; + /** Defaults to `RENDER_TILE_FORMAT`. */ + tileFormat?: number; +} + +function stableOverridesJson( + overrides: ColorStackOverrides | null | undefined, +): string { + if (!overrides) return "{}"; + const above = [...(overrides.above ?? [])].sort((a, b) => a - b); + const below = [...(overrides.below ?? [])].sort((a, b) => a - b); + return JSON.stringify({ above, below }); +} + +/** + * SHA-256 of the render inputs, truncated to 32 hex chars (128 bits). + * Stable across processes; any input change yields a new key. + */ +export function computeRenderKey(input: RenderKeyInput): string { + const payload = [ + input.fileHash, + String(input.rotationCorrection ?? 0), + input.colorProfile, + stableOverridesJson(input.colorOverrides), + input.northLinesBelow ? "1" : "0", + String(input.tileFormat ?? RENDER_TILE_FORMAT), + ].join("|"); + return createHash("sha256").update(payload).digest("hex").slice(0, 32); +} + +/** SHA-256 hex of an OCAD blob — stored as `file_hash`. */ +export function hashMapFileData(fileData: Buffer | Uint8Array): string { + return createHash("sha256").update(fileData).digest("hex"); +} diff --git a/packages/api/src/map-tiles.ts b/packages/api/src/map-tiles.ts index dcbf547..308f5cb 100644 --- a/packages/api/src/map-tiles.ts +++ b/packages/api/src/map-tiles.ts @@ -4,10 +4,12 @@ * Two Fastify routes are exposed (registered via `registerMapTileRoutes`): * * GET /api/map-tile/:nameId/:z/:x/:y - * Returns a 256×256 PNG tile for the event identified by `nameId` - * (the URL slug, matching `Event.nameId`). Tiles are cached in the - * `map_tiles` table; a miss renders the block of tiles around the - * request and writes them all back. + * Returns a 256×512 PNG tile for the event identified by `nameId` + * (the URL slug, matching `Event.nameId`). Top half = opaque full + * composite; bottom half = transparent ink layer (colours above + * lower purple). The viewer slices the halves so course purple can + * sit between them. Tiles are cached in `map_tiles`; a miss renders + * the block of tiles around the request and writes them all back. * * GET /api/map-tile-progress * Pre-cache progress for the event identified by the @@ -80,22 +82,36 @@ import { svgCacheEvents, windowMaxPixels, } from "./map-render-limits.js"; +import { + applyIofColorStack, + filterNorthLineObjects, + inkToColor, + type StackOcadFile, +} from "./map-color-stack.js"; +import { ensureEventMapRenderKey } from "./map-render-cache.js"; +import type { ColorProfile, ColorStackOverrides } from "@oxygen/shared"; const TILE_SIZE = 256; -/** The parsed map: an SVG document plus the georeferencing to place it. */ +/** + * Bumped when the on-wire tile format changes so browsers' week-long + * cache cannot feed an old 256×256 PNG into the stacked-tile slicer. + * Folded into `computeRenderKey` and the client `?f=` query. + */ +export const TILE_FORMAT = 2; + +/** The parsed map: SVG documents plus the georeferencing to place them. */ interface MapSource { + /** Opaque full composite (all colours after IOF stack rewrite). */ svg: string; + /** Transparent ink layer, or null when nothing sits above lower purple. */ + svgInk: string | null; rootViewBox: ViewBox; crs: OcadCrs; ocadBounds: number[]; mapWgs84: WGS84Bounds; - /** - * `uploadedAt` of the map file this was parsed from. Used to notice a - * map that was replaced on a different instance — `onMapUpload` only - * fires in the process that handled the upload. - */ - uploadedAtMs: number; + /** Content-keyed cache identity for this parse. */ + renderKey: string; } /** A rasterised region of the map, in OCAD coordinates. */ @@ -109,9 +125,9 @@ interface RenderedWindow { densityY: number; } -// Per-event parsed SVG (a few MB each), keyed by `event.id`. -const svgCache = new Map(); -const svgLoadInFlight = new Map>(); +// Per-renderKey parsed SVG (a few MB each). +const svgCache = new Map(); +const svgLoadInFlight = new Map>(); // De-dupes concurrent renders of the same block: a viewport fetches ~20 // tiles at once, which is one or two blocks. const blockInFlight = new Map>>(); @@ -134,16 +150,33 @@ function gate(): Semaphore { return renderGate; } -/** Drop cached state for `eventId` — called whenever a new map is uploaded. */ -function invalidate(eventId: bigint): void { - svgCache.delete(eventId); - svgLoadInFlight.delete(eventId); - preCacheConsidered.delete(eventId); +/** Drop in-process caches for a render key (profile/rotation change). */ +export function invalidateRenderKey(renderKey: string): void { + svgCache.delete(renderKey); + svgLoadInFlight.delete(renderKey); + preCacheConsidered.delete(renderKey); for (const key of [...blockInFlight.keys()]) { - if (key.startsWith(`${eventId}:`)) blockInFlight.delete(key); + if (key.startsWith(`${renderKey}:`)) blockInFlight.delete(key); } } +/** Drop in-process caches for an event (map upload / replace). */ +function invalidateEvent(eventId: bigint): void { + // We don't know the old render key cheaply; clear entries that match + // any currently-cached source for this event by wiping nothing event- + // scoped. Upload paths call gcOrphanTiles + ensureEventMapRenderKey + // which yields a fresh key; any stale svgCache entry for the old key + // is orphaned and evicted by size. Also drop preCacheConsidered entries + // wholesale when the map changes — cheapest correct approach. + preCacheConsidered.clear(); + // Drop every svg cache entry: uploads are rare and the SVG is rebuilt + // on the next tile miss. Safer than leaving a stale ink layer. + svgCache.clear(); + svgLoadInFlight.clear(); + blockInFlight.clear(); + void eventId; +} + async function resolveEventId(nameId: string): Promise { if (!nameId) return null; const row = await prisma().event.findUnique({ @@ -158,52 +191,41 @@ const tileKey = (z: number, x: number, y: number) => `${z}/${x}/${y}`; // ─── Map source ───────────────────────────────────────────── /** - * The parsed SVG for an event's current map. - * - * The cache is validated against the map file's `uploadedAt` rather than - * trusted outright: `onMapUpload` only fires in the process that handled - * the upload, so on any other instance this metadata read is the only - * thing standing between a replaced map and tiles rendered from the old - * one. It costs one indexed row read, and only on the tile-miss path — - * a cache hit never gets here, and a miss is about to spend two orders - * of magnitude more time rasterising. + * The parsed SVG for an event's current map, keyed by renderKey. */ async function getMapSource(eventId: bigint): Promise { - const stamp = await currentMapStamp(eventId); - if (stamp === null) throw new Error("No map file uploaded"); + const meta = await ensureEventMapRenderKey(prisma(), eventId); + if (!meta) throw new Error("No map file uploaded"); + const { renderKey } = meta; - const cached = svgCache.get(eventId); - if (cached?.uploadedAtMs === stamp) return cached; - if (cached) invalidate(eventId); + const cached = svgCache.get(renderKey); + if (cached) return cached; - const inFlight = svgLoadInFlight.get(eventId); + const inFlight = svgLoadInFlight.get(renderKey); if (inFlight) return inFlight; - const promise = loadMapSource(eventId, stamp); - svgLoadInFlight.set(eventId, promise); + const promise = loadMapSource(eventId, meta); + svgLoadInFlight.set(renderKey, promise); try { const source = await promise; evictForInsert(svgCache, svgCacheEvents()); - svgCache.set(eventId, source); + svgCache.set(renderKey, source); return source; } finally { - svgLoadInFlight.delete(eventId); + svgLoadInFlight.delete(renderKey); } } -/** `uploadedAt` of the event's newest map file, or null if it has none. */ -async function currentMapStamp(eventId: bigint): Promise { - const meta = await prisma().mapFile.findFirst({ - where: { eventId }, - orderBy: { id: "desc" }, - select: { uploadedAt: true }, - }); - return meta ? meta.uploadedAt.getTime() : null; -} - async function loadMapSource( eventId: bigint, - uploadedAtMs: number, + meta: { + renderKey: string; + rotationCorrection: number; + colorProfile: ColorProfile; + colorOverrides: ColorStackOverrides; + northLinesBelow: boolean; + scale: number | null; + }, ): Promise { const row = await prisma().mapFile.findFirst({ where: { eventId }, @@ -218,10 +240,13 @@ async function loadMapSource( const readOcad = (ocadMod as Record).readOcad as ( buf: Buffer, opts?: Record, - ) => Promise<{ - getCrs(): OcadCrs; - getBounds(): number[]; - }>; + ) => Promise< + StackOcadFile & { + objects?: Array<{ sym?: number }>; + getCrs(): OcadCrs; + getBounds(): number[]; + } + >; const ocadToSvg = (ocadMod as Record).ocadToSvg as ( file: unknown, opts: Record, @@ -233,17 +258,44 @@ async function loadMapSource( const ocadFile = await readOcad(Buffer.from(row.fileData), { quietWarnings: true, }); + const stack = applyIofColorStack(ocadFile, { + profile: meta.colorProfile, + overrides: meta.colorOverrides, + scale: meta.scale, + }); + if (stack.warnings.length > 0) { + console.warn( + `[map-color-stack] event ${eventId}: ${stack.warnings.join("; ")}`, + ); + } + const inkTo = inkToColor(stack); + const svg = ocadToSvg(ocadFile, { document: dom.window.document, generateSymbolElements: true, exportHidden: false, }).outerHTML; + const inkObjects = meta.northLinesBelow + ? filterNorthLineObjects(ocadFile.objects) + : ocadFile.objects; + const svgInk = + inkTo == null + ? null + : ocadToSvg(ocadFile, { + document: dom.window.document, + generateSymbolElements: true, + exportHidden: false, + toColor: inkTo, + fill: "transparent", + ...(inkObjects ? { objects: inkObjects } : {}), + }).outerHTML; + // Apply the same north/grivation correction used when deriving // bounds / northOffset so tiles warp into true-north mercator. const crs = withGrivationCorrection( ocadFile.getCrs(), - row.rotationCorrection, + meta.rotationCorrection, ); const ocadBounds = ocadFile.getBounds(); const mapWgs84 = ocadBoundsToWgs84(ocadBounds, crs); @@ -252,7 +304,15 @@ async function loadMapSource( const rootViewBox = parseViewBox(svg); if (!rootViewBox) throw new Error("Map SVG has no root viewBox"); - return { svg, rootViewBox, crs, ocadBounds, mapWgs84, uploadedAtMs }; + return { + svg, + svgInk, + rootViewBox, + crs, + ocadBounds, + mapWgs84, + renderKey: meta.renderKey, + }; } // ─── Geometry ─────────────────────────────────────────────── @@ -280,16 +340,19 @@ function tileQuad( // ─── Rendering ────────────────────────────────────────────── /** - * Rasterise `rect` out of the map SVG at `density` pixels per OCAD unit. + * Rasterise `rect` out of an SVG at `density` pixels per OCAD unit. * The density that comes back is measured from the produced image rather * than assumed, so the sampler is immune to the rasteriser's rounding. */ async function rasterise( - source: MapSource, + svg: string, + rootViewBox: ViewBox, + ocadBounds: number[], rect: OcadRect, density: number, + transparent: boolean, ): Promise { - const viewBox = windowViewBox(source.rootViewBox, source.ocadBounds, rect); + const viewBox = windowViewBox(rootViewBox, ocadBounds, rect); if (!viewBox) { // The generator's root viewBox no longer spans the OCAD bounds, so the // window placement can't be trusted. Fail loudly instead of silently @@ -306,9 +369,9 @@ async function rasterise( // more often than the whole-map version it replaced — once per block // per zoom rather than once per map — so a synchronous render stalls // every other request in the process while tiles are being produced. - const rendered = await resvgMod.renderAsync(withViewBox(source.svg, viewBox), { + const rendered = await resvgMod.renderAsync(withViewBox(svg, viewBox), { fitTo: { mode: "width" as const, value: size.width }, - background: "white", + ...(transparent ? {} : { background: "white" }), }); if (rendered.width === 0 || rendered.height === 0) return null; @@ -323,14 +386,17 @@ async function rasterise( } /** - * Warp one tile out of a rendered window with bilinear sampling. Returns - * null when the tile has no content at all (fully transparent), which the - * caller turns into a 200 transparent PNG. + * Warp one tile out of a rendered window with bilinear sampling. + * When `premultiply` is true (transparent ink layer), samples in + * premultiplied space so edges against transparent pixels do not + * darken, then un-premultiplies. Returns a TILE_SIZE² RGBA buffer, or + * null when the tile has no content at all. */ -async function sampleTile( +function sampleTileRgba( quad: OcadQuad, win: RenderedWindow, -): Promise { + premultiply: boolean, +): Buffer | null { const toPx = (p: { x: number; y: number }) => ({ bx: (p.x - win.rect.minX) * win.densityX, by: (win.rect.maxY - p.y) * win.densityY, @@ -372,33 +438,68 @@ async function sampleTile( const i11 = ((y0 + 1) * win.width + (x0 + 1)) * 4; const dstOff = (ty * TILE_SIZE + tx) * 4; - for (let ch = 0; ch < 4; ch++) { - out[dstOff + ch] = Math.round( - win.pixels[i00 + ch] * w00 + - win.pixels[i10 + ch] * w10 + - win.pixels[i01 + ch] * w01 + - win.pixels[i11 + ch] * w11, - ); + if (premultiply) { + let r = 0; + let g = 0; + let b = 0; + let a = 0; + const add = (i: number, w: number) => { + const aa = win.pixels[i + 3] / 255; + r += win.pixels[i] * aa * w; + g += win.pixels[i + 1] * aa * w; + b += win.pixels[i + 2] * aa * w; + a += aa * w; + }; + add(i00, w00); + add(i10, w10); + add(i01, w01); + add(i11, w11); + if (a > 0) { + out[dstOff] = Math.round(r / a); + out[dstOff + 1] = Math.round(g / a); + out[dstOff + 2] = Math.round(b / a); + out[dstOff + 3] = Math.round(a * 255); + hasContent = true; + } + } else { + for (let ch = 0; ch < 4; ch++) { + out[dstOff + ch] = Math.round( + win.pixels[i00 + ch] * w00 + + win.pixels[i10 + ch] * w10 + + win.pixels[i01 + ch] * w01 + + win.pixels[i11 + ch] * w11, + ); + } + if (out[dstOff + 3] > 0) hasContent = true; } - if (out[dstOff + 3] > 0) hasContent = true; } } - if (!hasContent) return null; + return hasContent ? out : null; +} +/** Pack composite (top) + ink (bottom) into one 256×512 PNG. */ +async function packStackedTile( + composite: Buffer, + ink: Buffer | null, +): Promise { + const stacked = Buffer.alloc(TILE_SIZE * TILE_SIZE * 2 * 4); + composite.copy(stacked, 0); + if (ink) ink.copy(stacked, TILE_SIZE * TILE_SIZE * 4); const sharpMod = await import("sharp"); return sharpMod - .default(out, { raw: { width: TILE_SIZE, height: TILE_SIZE, channels: 4 } }) + .default(stacked, { + raw: { width: TILE_SIZE, height: TILE_SIZE * 2, channels: 4 }, + }) .png() .toBuffer(); } /** * Render every tile of one aligned block and persist them. Returns the - * PNGs keyed by `z/x/y`; tiles that fall outside the map are absent. + * stacked PNGs keyed by `z/x/y`; tiles that fall outside the map are absent. */ async function renderBlockUncached( - eventId: bigint, source: MapSource, z: number, bx: number, @@ -430,23 +531,49 @@ async function renderBlockUncached( const rect = boundsOfPoints(corners, 2 / wanted); const density = clampDensity(rect, wanted, windowMaxPixels()); - const win = await gate().run(() => rasterise(source, rect, density), { - background, - }); + const win = await gate().run( + () => + rasterise( + source.svg, + source.rootViewBox, + source.ocadBounds, + rect, + density, + false, + ), + { background }, + ); if (!win) return result; + let inkWin: RenderedWindow | null = null; + if (source.svgInk) { + inkWin = await gate().run( + () => + rasterise( + source.svgInk!, + source.rootViewBox, + source.ocadBounds, + rect, + density, + true, + ), + { background }, + ); + } + for (const [key, quad] of quads) { - const png = await sampleTile(quad, win); - if (png) result.set(key, png); + const composite = sampleTileRgba(quad, win, false); + if (!composite) continue; + const ink = inkWin ? sampleTileRgba(quad, inkWin, true) : null; + result.set(key, await packStackedTile(composite, ink)); } - await persistTiles(eventId, result); + await persistTiles(source.renderKey, result); return result; } /** Render a block, joining an in-flight render of the same block. */ async function renderBlock( - eventId: bigint, source: MapSource, z: number, x: number, @@ -456,12 +583,12 @@ async function renderBlock( const size = blockTiles(); const bx = blockOrigin(x, size); const by = blockOrigin(y, size); - const key = `${eventId}:${z}:${bx}:${by}`; + const key = `${source.renderKey}:${z}:${bx}:${by}`; const inFlight = blockInFlight.get(key); if (inFlight) return inFlight; - const promise = renderBlockUncached(eventId, source, z, bx, by, size, background); + const promise = renderBlockUncached(source, z, bx, by, size, background); blockInFlight.set(key, promise); try { return await promise; @@ -476,7 +603,7 @@ async function renderBlock( * concurrently — the rows are identical either way. */ async function persistTiles( - eventId: bigint, + renderKey: string, tiles: Map, ): Promise { if (tiles.size === 0) return; @@ -485,14 +612,14 @@ async function persistTiles( let i = 1; for (const [key, png] of tiles) { const [z, x, y] = key.split("/").map(Number); - rows.push(`($${i++}::bigint, $${i++}, $${i++}, $${i++}, $${i++})`); - values.push(eventId, z, x, y, png); + rows.push(`($${i++}, $${i++}, $${i++}, $${i++}, $${i++})`); + values.push(renderKey, z, x, y, png); } try { await prisma().$executeRawUnsafe( - `INSERT INTO oxygen.map_tiles (event_id, z, x, y, tile_data) + `INSERT INTO oxygen.map_tiles (render_key, z, x, y, tile_data) VALUES ${rows.join(", ")} - ON CONFLICT (event_id, z, x, y) DO NOTHING`, + ON CONFLICT (render_key, z, x, y) DO NOTHING`, ...values, ); } catch (err) { @@ -514,22 +641,23 @@ async function persistTiles( * latency. Deep zooms are left out of the span entirely; see * PRECACHE_MAX_ZOOM. */ -async function preCacheTiles(eventId: bigint, source: MapSource): Promise { +async function preCacheTiles(source: MapSource): Promise { const db = prisma(); const size = blockTiles(); const delayMs = precacheBlockDelayMs(); const maxZoom = precacheMaxZoom(); + const renderKey = source.renderKey; for (let z = precacheMinZoom(); z <= maxZoom; z++) { const range = tileRangeForBounds(source.mapWgs84, z); const expected = (range.x1 - range.x0 + 1) * (range.y1 - range.y0 + 1); - const have = await db.mapTile.count({ where: { eventId, z } }); + const have = await db.mapTile.count({ where: { renderKey, z } }); if (have >= expected) continue; for (const bx of blockRange(range.x0, range.x1, size)) { for (const by of blockRange(range.y0, range.y1, size)) { try { - await renderBlock(eventId, source, z, bx, by, true); + await renderBlock(source, z, bx, by, true); } catch (err) { const msg = String((err as Error)?.message ?? ""); if ( @@ -559,7 +687,7 @@ async function preCacheTiles(eventId: bigint, source: MapSource): Promise const CHUNK_BLOCKS = 2; /** Serialises work-stealing chunks so overlapping polls don't pile up. */ -const chunkInFlight = new Set(); +const chunkInFlight = new Set(); /** * Render up to `CHUNK_BLOCKS` still-missing blocks, inline. @@ -576,10 +704,12 @@ const chunkInFlight = new Set(); * the progress figures it already read. */ async function preCacheChunk(eventId: bigint): Promise { - if (!precacheEnabled() || chunkInFlight.has(eventId)) return 0; - chunkInFlight.add(eventId); + const meta = await ensureEventMapRenderKey(prisma(), eventId); + if (!meta || !precacheEnabled() || chunkInFlight.has(meta.renderKey)) return 0; + const { renderKey } = meta; + chunkInFlight.add(renderKey); try { - const bounds = await storedBounds(eventId); + const bounds = parseBounds(meta.bounds); if (!bounds) return 0; const db = prisma(); @@ -591,11 +721,11 @@ async function preCacheChunk(eventId: bigint): Promise { const range = tileRangeForBounds(bounds, z); const expected = (range.x1 - range.x0 + 1) * (range.y1 - range.y0 + 1); - const have = await db.mapTile.count({ where: { eventId, z } }); + const have = await db.mapTile.count({ where: { renderKey, z } }); if (have >= expected) continue; const rows = await db.mapTile.findMany({ - where: { eventId, z }, + where: { renderKey, z }, select: { x: true, y: true }, }); const present = new Set(rows.map((r) => `${r.x}/${r.y}`)); @@ -604,7 +734,7 @@ async function preCacheChunk(eventId: bigint): Promise { const source = await getMapSource(eventId); for (const { bx, by } of blocks) { try { - const tiles = await renderBlock(eventId, source, z, bx, by, true); + const tiles = await renderBlock(source, z, bx, by, true); written += tiles.size; } catch (err) { console.error( @@ -617,24 +747,14 @@ async function preCacheChunk(eventId: bigint): Promise { } return written; } finally { - chunkInFlight.delete(eventId); + chunkInFlight.delete(renderKey); } } // ─── Progress ─────────────────────────────────────────────── -/** - * The map's WGS84 bounds as stored at upload. Reading them back beats - * re-deriving them from the OCAD: it is one small query rather than a - * parse, so the progress endpoint and the pre-cache check stay cheap. - */ -async function storedBounds(eventId: bigint): Promise { - const row = await prisma().mapFile.findFirst({ - where: { eventId }, - orderBy: { uploadedAt: "desc" }, - select: { bounds: true }, - }); - const bounds = row?.bounds as unknown as WGS84Bounds | null; +function parseBounds(raw: unknown): WGS84Bounds | null { + const bounds = raw as WGS84Bounds | null; if ( !bounds || typeof bounds.north !== "number" || @@ -647,6 +767,16 @@ async function storedBounds(eventId: bigint): Promise { return bounds; } +/** + * The map's WGS84 bounds as stored at upload. Reading them back beats + * re-deriving them from the OCAD: it is one small query rather than a + * parse, so the progress endpoint and the pre-cache check stay cheap. + */ +async function storedBounds(eventId: bigint): Promise { + const meta = await ensureEventMapRenderKey(prisma(), eventId); + return meta ? parseBounds(meta.bounds) : null; +} + /** * Pre-cache progress straight from the database. The denominator is a * function of the map's stored WGS84 bounds and the numerator is a row @@ -656,14 +786,16 @@ async function storedBounds(eventId: bigint): Promise { async function tileProgress( eventId: bigint, ): Promise<{ total: number; done: number; rendering: boolean }> { - const bounds = await storedBounds(eventId); + const meta = await ensureEventMapRenderKey(prisma(), eventId); + if (!meta) return { total: 0, done: 0, rendering: false }; + const bounds = parseBounds(meta.bounds); if (!bounds) return { total: 0, done: 0, rendering: false }; const minZoom = precacheMinZoom(); const maxZoom = precacheMaxZoom(); const total = expectedTileCount(bounds, minZoom, maxZoom); const done = await prisma().mapTile.count({ - where: { eventId, z: { gte: minZoom, lte: maxZoom } }, + where: { renderKey: meta.renderKey, z: { gte: minZoom, lte: maxZoom } }, }); return { total, done: Math.min(done, total), rendering: done < total }; } @@ -675,7 +807,7 @@ async function tileProgress( * cache to `onMapUpload` invalidations. Call once during server boot. */ export function registerMapTileRoutes(server: FastifyInstance): void { - onMapUpload(invalidate); + onMapUpload(invalidateEvent); // Progress endpoint — polled by the frontend during the // "Generating tiles…" overlay. The event is identified by the @@ -732,8 +864,15 @@ export function registerMapTileRoutes(server: FastifyInstance): void { return reply.code(404).send({ error: "Unknown event" }); } + const meta = await ensureEventMapRenderKey(prisma(), eventId); + if (!meta) { + return reply.code(404).send({ error: "No map file uploaded" }); + } + const cached = await prisma().mapTile.findUnique({ - where: { eventId_z_x_y: { eventId, z, x, y } }, + where: { + renderKey_z_x_y: { renderKey: meta.renderKey, z, x, y }, + }, select: { tileData: true }, }); if (cached) { @@ -746,7 +885,7 @@ export function registerMapTileRoutes(server: FastifyInstance): void { try { const source = await getMapSource(eventId); - const rendered = await renderBlock(eventId, source, z, x, y); + const rendered = await renderBlock(source, z, x, y); const png = rendered.get(tileKey(z, x, y)); if (!png) { // Outside the map, or an empty tile — return a transparent PNG @@ -776,39 +915,38 @@ export function registerMapTileRoutes(server: FastifyInstance): void { } /** - * Events this process has already considered for pre-caching, whether or - * not it went on to render anything. Requests arrive in viewport-sized - * bursts, so without this the completeness check below would run twenty - * times over. + * Render keys this process has already considered for pre-caching, + * whether or not it went on to render anything. Requests arrive in + * viewport-sized bursts, so without this the completeness check below + * would run twenty times over. */ -const preCacheConsidered = new Set(); +const preCacheConsidered = new Set(); /** - * Start the background pre-cache if this event still needs it. - * - * Called from the cache-hit path as well as the render path, so a - * process that restarts mid-pre-cache (or inherits a partially filled - * cache from another instance) finishes the job rather than waiting for - * someone to happen upon an uncached tile. The completeness check runs - * off the stored bounds and a row count, so the common "already done" - * case costs two queries and never touches the OCAD. + * Start the background pre-cache if this event's render key still needs it. */ async function maybePreCache(eventId: bigint): Promise { - if (!precacheEnabled() || preCacheConsidered.has(eventId)) return; - preCacheConsidered.add(eventId); + if (!precacheEnabled()) return; + const meta = await ensureEventMapRenderKey(prisma(), eventId); + if (!meta) return; + if (preCacheConsidered.has(meta.renderKey)) return; + preCacheConsidered.add(meta.renderKey); - const bounds = await storedBounds(eventId); + const bounds = parseBounds(meta.bounds); if (!bounds) return; const minZoom = precacheMinZoom(); const maxZoom = precacheMaxZoom(); const done = await prisma().mapTile.count({ - where: { eventId, z: { gte: minZoom, lte: maxZoom } }, + where: { + renderKey: meta.renderKey, + z: { gte: minZoom, lte: maxZoom }, + }, }); if (done >= expectedTileCount(bounds, minZoom, maxZoom)) return; const source = await getMapSource(eventId); - await preCacheTiles(eventId, source); + await preCacheTiles(source); } function kickOffPreCache(eventId: bigint): void { diff --git a/packages/api/src/overprint-cuts.ts b/packages/api/src/overprint-cuts.ts index 9a65dfb..3bd67a6 100644 --- a/packages/api/src/overprint-cuts.ts +++ b/packages/api/src/overprint-cuts.ts @@ -1,13 +1,12 @@ /** * Automatic course-overprint cuts. * - * Course setters cut the purple overprint wherever it would hide important - * map detail: circle slits where a control circle crosses black features - * (rock, man-made) or knolls, and leg-line gaps where a leg passes over - * black features. This module computes those cuts from the base map's - * parsed OCAD objects (`event-map-objects.ts`) at geometry-rebuild time, - * so they are stored in the course GeoJSON and render everywhere without - * any manual work. + * Course setters cut the purple overprint where it would hide a compact + * knoll or rock point feature. Long line and area objects remain uncut: + * the IOF colour stack keeps their ink readable without fragmenting the + * course. This module computes the cuts from the base map's parsed OCAD + * objects (`event-map-objects.ts`) at geometry-rebuild time, so they are + * stored in the course GeoJSON and render everywhere. * * Everything here is pure and unit-tested. Inputs are paper mm (the unit * of `controls.xpos/ypos` and course GeoJSON); `SlimMapObject` coordinates @@ -30,44 +29,14 @@ export interface LegGap { } /** - * ISOM symbols whose ink the overprint must not hide, keyed by ISOM - * number (`floor(ocadSym / 1000)`). - * - * - `point`: compact black point symbols (boulders, towers, cairns …) - * plus knolls (109/110 — brown, but small enough that a purple line - * over one makes it unreadable). - * - `line`: black line symbols — cut where the circle rim or leg - * *crosses* them. - * - `area`: solid-black-ish areas (buildings, canopies, ruins, gigantic - * boulders) — cut the whole stretch of rim/leg *inside* them. - * - * Deliberately excluded: pattern-fill areas (boulder fields, stony - * ground — their dots are symbol fill, not objects, so a cut at the - * invisible area boundary would look random), bare rock (grey), paved - * area (brown fill), and all water / marsh / vegetation symbols. + * Compact ISOM point symbols that receive automatic cuts. Restricting this + * to point-sized knolls and rocks avoids needless slits over cliffs, paths, + * buildings, walls and other long objects now handled by colour stacking. */ -const CUT_KINDS: Record = { - // Knolls (brown) — slits only, never leg gaps (see KNOLLS below). - 109: "point", 110: "point", - // Rock (black). - 201: "line", 202: "line", - 203: "point", 204: "point", 205: "point", - 206: "area", 207: "point", - 215: "line", - // Man-made (black). - 502: "line", 503: "line", 504: "line", 505: "line", 506: "line", - 507: "line", 508: "line", 509: "line", 510: "line", 511: "line", - 512: "line", 513: "line", 514: "line", 515: "line", 516: "line", - 517: "line", 518: "line", - 521: "area", 522: "area", 523: "area", - 524: "point", 525: "point", 526: "point", 527: "point", - 528: "line", 529: "line", - 530: "point", 531: "point", - 532: "line", -}; - -/** Knolls cut circles but not legs — a leg over a knoll is convention. */ -const KNOLLS = new Set([109, 110]); +const CUT_SYMBOLS = new Set([ + 109, 110, // knoll, elongated knoll + 203, 204, 205, 207, // rocky pit, boulder, large boulder, boulder cluster +]); /** OCAD units per paper mm (SlimMapObject coordinates). */ const UNITS_PER_MM = 100; @@ -87,10 +56,6 @@ const POINT_REACH_MM = 0.45; * only fragments the circle without revealing more map. */ const POINT_CUT_HALF_MM = 0.4; -/** Half-width of ink to clear around a black line crossing, paper mm. */ -const LINE_REACH_MM = 0.35; -/** Rim sampling step for area-interior spans, degrees. */ -const AREA_STEP_DEG = 4; /** Slits narrower than this are dropped (invisible anyway). */ const MIN_SLIT_DEG = 4; /** If cuts would leave less than this much circle, keep it whole. */ @@ -101,27 +66,18 @@ const LEG_END_KEEP_MM = 1.2 * CIRCLE_RADIUS_MM; const MIN_LEG_GAP_MM = 0.6; /** Never gap away more than this fraction of a leg. */ const MAX_TOTAL_LEG_GAP = 0.7; -/** Longest half-gap a single oblique line crossing may open, paper mm. */ -const MAX_LINE_HALF_MM = 1.2; - type Pt = [number, number]; -function cutKindFor(sym: number): "point" | "line" | "area" | null { - return CUT_KINDS[Math.floor(sym / 1000)] ?? null; +function isCutObject(obj: SlimMapObject): boolean { + return obj.objType === 1 && CUT_SYMBOLS.has(Math.floor(obj.sym / 1000)); } -/** Object coordinates in paper mm, closing area rings. */ +/** Object coordinates in paper mm. */ function objectRingsMm(obj: SlimMapObject): Pt[] { - const pts: Pt[] = obj.coordinates.map(([x, y]) => [ + return obj.coordinates.map(([x, y]) => [ x / UNITS_PER_MM, y / UNITS_PER_MM, ]); - if (obj.objType === 3 && pts.length > 2) { - const [fx, fy] = pts[0]; - const [lx, ly] = pts[pts.length - 1]; - if (fx !== lx || fy !== ly) pts.push([fx, fy]); - } - return pts; } /** Cheap bbox rejection, `pad` in paper mm around the query point. */ @@ -133,20 +89,6 @@ function bboxMiss(obj: SlimMapObject, xMm: number, yMm: number, pad: number): bo return x < minX - p || x > maxX + p || y < minY - p || y > maxY + p; } -/** Ray-cast point-in-polygon over a closed ring. */ -function pointInRing(p: Pt, ring: Pt[]): boolean { - let inside = false; - for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { - const [xi, yi] = ring[i]; - const [xj, yj] = ring[j]; - const straddles = yi > p[1] !== yj > p[1]; - if (straddles && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) { - inside = !inside; - } - } - return inside; -} - // ─── Circle slits ──────────────────────────────────────────── interface ArcGap { @@ -206,34 +148,8 @@ function mergeArcGaps(gaps: ArcGap[]): SlitGap[] { } /** - * Intersection angles (compass deg) of segment a→b with the circle of - * radius r around c. - */ -function segmentCircleCrossings(a: Pt, b: Pt, c: Pt, r: number): number[] { - const dx = b[0] - a[0]; - const dy = b[1] - a[1]; - const fx = a[0] - c[0]; - const fy = a[1] - c[1]; - const A = dx * dx + dy * dy; - if (A === 0) return []; - const B = 2 * (fx * dx + fy * dy); - const C = fx * fx + fy * fy - r * r; - const disc = B * B - 4 * A * C; - if (disc <= 0) return []; - const sq = Math.sqrt(disc); - const out: number[] = []; - for (const t of [(-B - sq) / (2 * A), (-B + sq) / (2 * A)]) { - if (t < 0 || t > 1) continue; - const p: Pt = [a[0] + t * dx, a[1] + t * dy]; - out.push(compassBearing(c, p)); - } - return out; -} - -/** - * Automatic slits for a control circle centred at (xMm, yMm): black point - * features and knolls on the rim, black line crossings, and rim stretches - * inside solid black areas. + * Automatic slits for a control circle centred at (xMm, yMm): compact + * knoll and rock point features on the rim. */ export function circleCuts( objects: SlimMapObject[], @@ -245,55 +161,16 @@ export function circleCuts( const gaps: ArcGap[] = []; const pointHalfDeg = (Math.asin(Math.min(0.95, POINT_CUT_HALF_MM / radiusMm)) * 180) / Math.PI; - const lineHalfDeg = - (Math.asin(Math.min(0.95, LINE_REACH_MM / radiusMm)) * 180) / Math.PI; for (const obj of objects) { - const kind = cutKindFor(obj.sym); - if (!kind) continue; + if (!isCutObject(obj)) continue; if (bboxMiss(obj, xMm, yMm, radiusMm + POINT_REACH_MM)) continue; const pts = objectRingsMm(obj); - - if (kind === "point") { - const p = pts[0]; - const d = Math.hypot(p[0] - c[0], p[1] - c[1]); - if (Math.abs(d - radiusMm) <= POINT_REACH_MM) { - pushSlit(gaps, compassBearing(c, p), pointHalfDeg); - } - } else if (kind === "line") { - for (let i = 0; i < pts.length - 1; i++) { - for (const deg of segmentCircleCrossings(pts[i], pts[i + 1], c, radiusMm)) { - pushSlit(gaps, deg, lineHalfDeg); - } - } - } else { - if (pts.length < 4) continue; - // Sample the rim; contiguous inside-runs become one gap each. - const steps = Math.round(360 / AREA_STEP_DEG); - const inside: boolean[] = []; - for (let i = 0; i < steps; i++) { - const deg = i * AREA_STEP_DEG; - const rad = (deg * Math.PI) / 180; - // Compass: 0° = +y (north), clockwise. - const p: Pt = [c[0] + radiusMm * Math.sin(rad), c[1] + radiusMm * Math.cos(rad)]; - inside.push(pointInRing(p, pts)); - } - if (inside.every(Boolean)) return []; // whole rim buried: keep circle - // Walk runs starting from a known-outside sample so wraps are easy. - const startIdx = inside.findIndex((v) => !v); - let runStart = -1; - for (let k = 0; k <= steps; k++) { - const i = (startIdx + k) % steps; - if (k < steps && inside[i]) { - if (runStart < 0) runStart = i; - } else if (runStart >= 0) { - const runLen = (i - runStart + steps) % steps || steps; - const startDeg = runStart * AREA_STEP_DEG - AREA_STEP_DEG / 2; - const width = runLen * AREA_STEP_DEG + AREA_STEP_DEG; - gaps.push({ start: ((startDeg % 360) + 360) % 360, end: ((startDeg % 360) + 360) % 360 + width }); - runStart = -1; - } - } + const p = pts[0]; + if (!p) continue; + const d = Math.hypot(p[0] - c[0], p[1] - c[1]); + if (Math.abs(d - radiusMm) <= POINT_REACH_MM) { + pushSlit(gaps, compassBearing(c, p), pointHalfDeg); } } @@ -302,22 +179,6 @@ export function circleCuts( // ─── Leg gaps ──────────────────────────────────────────────── -/** Intersection parameter of leg a→b with segment p→q, or null. */ -function segSegIntersection(a: Pt, b: Pt, p: Pt, q: Pt): { t: number; sinAngle: number } | null { - const r: Pt = [b[0] - a[0], b[1] - a[1]]; - const s: Pt = [q[0] - p[0], q[1] - p[1]]; - const denom = r[0] * s[1] - r[1] * s[0]; - if (denom === 0) return null; - const qp: Pt = [p[0] - a[0], p[1] - a[1]]; - const t = (qp[0] * s[1] - qp[1] * s[0]) / denom; - const u = (qp[0] * r[1] - qp[1] * r[0]) / denom; - if (t < 0 || t > 1 || u < 0 || u > 1) return null; - const lenR = Math.hypot(r[0], r[1]); - const lenS = Math.hypot(s[0], s[1]); - const sinAngle = lenR && lenS ? Math.abs(denom) / (lenR * lenS) : 1; - return { t, sinAngle }; -} - /** Distance from p to segment a→b plus the projection parameter. */ function pointSegDistance(p: Pt, a: Pt, b: Pt): { dist: number; t: number } { const dx = b[0] - a[0]; @@ -334,9 +195,8 @@ function pointSegDistance(p: Pt, a: Pt, b: Pt): { dist: number; t: number } { } /** - * Automatic gaps for the straight leg a→b (paper mm): black point - * features on the line, black line crossings, and stretches inside solid - * black areas. Knolls are excluded — slits only. + * Automatic gaps for the straight leg a→b (paper mm): compact knoll and + * rock point features on the line. */ export function legGaps(aMm: Pt, bMm: Pt, objects: SlimMapObject[]): LegGap[] { const legLen = Math.hypot(bMm[0] - aMm[0], bMm[1] - aMm[1]); @@ -353,50 +213,13 @@ export function legGaps(aMm: Pt, bMm: Pt, objects: SlimMapObject[]): LegGap[] { }; for (const obj of objects) { - const isom = Math.floor(obj.sym / 1000); - if (KNOLLS.has(isom)) continue; - const kind = cutKindFor(obj.sym); - if (!kind) continue; + if (!isCutObject(obj)) continue; if (bboxMiss(obj, midX, midY, reachPad)) continue; const pts = objectRingsMm(obj); - - if (kind === "point") { - const { dist, t } = pointSegDistance(pts[0], aMm, bMm); - if (dist <= POINT_REACH_MM) pushMm(t, POINT_CUT_HALF_MM); - } else if (kind === "line") { - for (let i = 0; i < pts.length - 1; i++) { - const hit = segSegIntersection(aMm, bMm, pts[i], pts[i + 1]); - if (!hit) continue; - // Oblique crossings hide a longer stretch of the line. - const halfMm = Math.min( - LINE_REACH_MM / Math.max(hit.sinAngle, LINE_REACH_MM / MAX_LINE_HALF_MM), - MAX_LINE_HALF_MM, - ); - pushMm(hit.t, halfMm); - } - } else { - if (pts.length < 4) continue; - // Entry/exit parameters against the ring, toggled from the start - // point's inside-status. - const ts: number[] = [0, 1]; - for (let i = 0; i < pts.length - 1; i++) { - const hit = segSegIntersection(aMm, bMm, pts[i], pts[i + 1]); - if (hit) ts.push(hit.t); - } - ts.sort((x, y) => x - y); - for (let i = 0; i < ts.length - 1; i++) { - const lo = ts[i]; - const hi = ts[i + 1]; - if (hi - lo <= 0) continue; - const mid: Pt = [ - aMm[0] + ((lo + hi) / 2) * (bMm[0] - aMm[0]), - aMm[1] + ((lo + hi) / 2) * (bMm[1] - aMm[1]), - ]; - if (pointInRing(mid, pts)) { - raw.push([lo - LINE_REACH_MM / legLen, hi + LINE_REACH_MM / legLen]); - } - } - } + const p = pts[0]; + if (!p) continue; + const { dist, t } = pointSegDistance(p, aMm, bMm); + if (dist <= POINT_REACH_MM) pushMm(t, POINT_CUT_HALF_MM); } if (raw.length === 0) return []; diff --git a/packages/api/src/routers/clubMap.ts b/packages/api/src/routers/clubMap.ts index 9204cb1..ad64eb7 100644 --- a/packages/api/src/routers/clubMap.ts +++ b/packages/api/src/routers/clubMap.ts @@ -13,6 +13,13 @@ import { renderOcadPreview } from "../club-map-preview.js"; import type { WGS84Bounds } from "../map-projection.js"; import type { NorthDetection } from "../map-north.js"; import type { Prisma } from "../generated/prisma/client.js"; +import { computeRenderKey, hashMapFileData } from "../map-render-key.js"; +import { + gcOrphanTiles, + refreshClubMapRenderKey, +} from "../map-render-cache.js"; +import { colorProfileSchema, colorStackOverridesSchema } from "@oxygen/shared"; +import { invalidateRenderKey } from "../map-tiles.js"; function toId(id: bigint): number { return Number(id); @@ -32,6 +39,9 @@ export const clubMapRouter = router({ northOffset: true, rotationCorrection: true, northDetection: true, + colorProfile: true, + northLinesBelow: true, + renderKey: true, uploadedAt: true, uploadedBy: true, uploader: { select: { email: true, displayName: true } }, @@ -70,6 +80,9 @@ export const clubMapRouter = router({ northOffset: row.northOffset, rotationCorrection: row.rotationCorrection, northDetection: (row.northDetection as NorthDetection | null) ?? null, + colorProfile: row.colorProfile, + northLinesBelow: row.northLinesBelow, + renderKey: row.renderKey, // Evaluated for today, not import time, so a library map starts // to flag its north lines as the declination drifts away from them. meridianStalenessDeg: currentMeridianStaleness(row.northDetection), @@ -92,6 +105,15 @@ export const clubMapRouter = router({ const resolved = await resolveMapNorth(buffer); const previewPng = await renderOcadPreview(buffer); const name = input.name?.trim() || input.fileName; + const fileHash = hashMapFileData(buffer); + const colorProfile = "auto" as const; + const northLinesBelow = true; + const renderKey = computeRenderKey({ + fileHash, + rotationCorrection: resolved.rotationCorrection, + colorProfile, + northLinesBelow, + }); const row = await prisma().clubMapFile.create({ data: { name, @@ -108,6 +130,10 @@ export const clubMapRouter = router({ northDetection: resolved.northDetection ? (resolved.northDetection as unknown as Prisma.InputJsonValue) : undefined, + colorProfile, + northLinesBelow, + fileHash, + renderKey, uploadedBy: ctx.user?.id ?? null, }, select: { @@ -116,6 +142,7 @@ export const clubMapRouter = router({ fileName: true, sizeBytes: true, rotationCorrection: true, + renderKey: true, }, }); return { @@ -124,10 +151,51 @@ export const clubMapRouter = router({ fileName: row.fileName, sizeBytes: row.sizeBytes, rotationCorrection: row.rotationCorrection, + renderKey: row.renderKey, meridianStalenessDeg: resolved.meridianStalenessDeg, }; }), + setColorStack: authedProcedure + .input( + z.object({ + id: z.number().int().positive(), + profile: colorProfileSchema.optional(), + northLinesBelow: z.boolean().optional(), + overrides: colorStackOverridesSchema.optional(), + }), + ) + .mutation(async ({ input }) => { + const id = BigInt(input.id); + const existing = await prisma().clubMapFile.findUnique({ + where: { id }, + select: { id: true, renderKey: true }, + }); + if (!existing) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Club map ${input.id} not found`, + }); + } + const data: Record = {}; + if (input.profile !== undefined) data.colorProfile = input.profile; + if (input.northLinesBelow !== undefined) { + data.northLinesBelow = input.northLinesBelow; + } + if (input.overrides !== undefined) { + data.colorOverrides = input.overrides; + } + if (Object.keys(data).length > 0) { + await prisma().clubMapFile.update({ where: { id }, data }); + } + const oldKey = existing.renderKey; + const renderKey = await refreshClubMapRenderKey(prisma(), id); + if (oldKey && oldKey !== renderKey) invalidateRenderKey(oldKey); + invalidateRenderKey(renderKey); + await gcOrphanTiles(prisma()); + return { success: true as const, renderKey }; + }), + /** * Ops-only escape hatch: manual georeference correction on a * club-library map. Not exposed in the UI — ScalePar is authoritative @@ -201,6 +269,7 @@ export const clubMapRouter = router({ } } await prisma().clubMapFile.delete({ where: { id: BigInt(input.id) } }); + await gcOrphanTiles(prisma()); return { success: true as const }; }), diff --git a/packages/api/src/routers/course.ts b/packages/api/src/routers/course.ts index f183e03..12c4171 100644 --- a/packages/api/src/routers/course.ts +++ b/packages/api/src/routers/course.ts @@ -41,9 +41,25 @@ import { synchronizePositionedControlCoordinates, type MapCalibrationPoint, } from "../event-map.js"; +import { cutRotationDeg } from "../map-north.js"; +import { + ensureEventMapRenderKey, + gcOrphanTiles, + refreshMapFileRenderKey, +} from "../map-render-cache.js"; +import { invalidateRenderKey } from "../map-tiles.js"; +import { + colorProfileSchema, + colorStackOverridesSchema, +} from "@oxygen/shared"; +import { applyIofColorStack, type StackOcadFile } from "../map-color-stack.js"; import { canDownloadEventMap } from "../ocad-export.js"; import { loadEventCrs } from "../event-crs.js"; -import { rebuildCourseGeometry } from "../course-geometry.js"; +import { + ensureOverprintCutsCurrent, + OVERPRINT_CUTS_VERSION, + rebuildCourseGeometry, +} from "../course-geometry.js"; import { appendJournal } from "../journalEmit.js"; import { emitClassUpserted, @@ -758,6 +774,7 @@ export const courseRouter = router({ geometry: coursesViewProcedure .input(z.object({ id: z.number().int() })) .query(async ({ ctx, input }) => { + await ensureOverprintCutsCurrent(ctx.db, ctx.event.id); const c = await ctx.db.course.findFirst({ where: { eventId: ctx.event.id, seq: input.id }, select: { geometry: true }, @@ -785,6 +802,7 @@ export const courseRouter = router({ .optional(), ) .query(async ({ ctx, input }) => { + await ensureOverprintCutsCurrent(ctx.db, ctx.event.id); const where: Record = { eventId: ctx.event.id, removed: false, @@ -902,6 +920,52 @@ export const courseRouter = router({ northDetection = detection as unknown as PrismaNs.JsonValue; } + const stackMeta = await ensureEventMapRenderKey(ctx.db, ctx.event.id); + if (!stackMeta) return null; + + let resolvedProfile = stackMeta.colorProfile === "auto" + ? (scale != null && scale > 0 && scale <= 5000 ? "issprom" : "isom") + : stackMeta.colorProfile; + let resolvedBy: + | "explicit" + | "file-colour" + | "scale" + | "default" = + stackMeta.colorProfile === "auto" + ? scale != null + ? "scale" + : "default" + : "explicit"; + + if (stackMeta.colorProfile === "auto") { + if (!blobBuffer) { + const blob = await ctx.db.mapFile.findUnique({ + where: { id: row.id }, + select: { fileData: true }, + }); + if (blob) blobBuffer = Buffer.from(blob.fileData); + } + if (blobBuffer) { + try { + const ocadMod = await import("ocad2geojson"); + const readOcad = (ocadMod as Record).readOcad as ( + buf: Buffer, + opts?: Record, + ) => Promise; + const ocad = await readOcad(blobBuffer, { quietWarnings: true }); + const classified = applyIofColorStack(ocad, { + profile: "auto", + overrides: stackMeta.colorOverrides, + scale, + }); + resolvedProfile = classified.resolvedProfile; + resolvedBy = classified.resolvedBy; + } catch { + // Keep the scale heuristic. + } + } + } + return { scale, bounds, @@ -910,6 +974,17 @@ export const courseRouter = router({ rotationCorrection, meridianStalenessDeg: currentMeridianStaleness(northDetection), uploadedAt: row.uploadedAt.getTime(), + cutRotationDeg: cutRotationDeg( + northOffset, + (northDetection as { meridian?: { medianTiltDeg?: number } } | null) + ?.meridian?.medianTiltDeg ?? null, + ), + colorProfile: stackMeta.colorProfile, + colorOverrides: stackMeta.colorOverrides, + northLinesBelow: stackMeta.northLinesBelow, + resolvedProfile, + resolvedBy, + renderKey: stackMeta.renderKey, }; }), @@ -1201,6 +1276,10 @@ export const courseRouter = router({ fileName: true, fileData: true, rotationCorrection: true, + colorProfile: true, + colorOverrides: true, + northLinesBelow: true, + fileHash: true, }, }); if (!row) { @@ -1217,11 +1296,105 @@ export const courseRouter = router({ { fromClubLibrary: true, rotationCorrection: row.rotationCorrection, + colorProfile: row.colorProfile as + | "auto" + | "isom" + | "issprom" + | "isskiom" + | "ismtbom", + colorOverrides: (row.colorOverrides ?? {}) as { + above?: number[]; + below?: number[]; + }, + northLinesBelow: row.northLinesBelow, + fileHash: row.fileHash ?? undefined, }, ); return { success: true as const, ...result }; }), + /** + * Update the event map's IOF colour-stack profile / north-line setting. + * Recomputes `render_key` and GCs orphaned tiles. + */ + setMapColorStack: coursesEditProcedure + .input( + z.object({ + profile: colorProfileSchema.optional(), + northLinesBelow: z.boolean().optional(), + overrides: colorStackOverridesSchema.optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const row = await ctx.db.mapFile.findFirst({ + where: { eventId: ctx.event.id }, + orderBy: { uploadedAt: "desc" }, + select: { id: true, renderKey: true }, + }); + if (!row) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "No map file uploaded", + }); + } + const data: Record = {}; + if (input.profile !== undefined) data.colorProfile = input.profile; + if (input.northLinesBelow !== undefined) { + data.northLinesBelow = input.northLinesBelow; + } + if (input.overrides !== undefined) { + data.colorOverrides = input.overrides; + } + if (Object.keys(data).length > 0) { + await ctx.db.mapFile.update({ where: { id: row.id }, data }); + } + const oldKey = row.renderKey; + const renderKey = await refreshMapFileRenderKey(ctx.db, row.id); + if (oldKey && oldKey !== renderKey) invalidateRenderKey(oldKey); + invalidateRenderKey(renderKey); + await gcOrphanTiles(ctx.db); + fireMapUpload(ctx.event.id); + return { success: true as const, renderKey }; + }), + + getOverprintCuts: coursesViewProcedure.query(async ({ ctx }) => { + const row = await ctx.db.event.findUnique({ + where: { id: ctx.event.id }, + select: { autoOverprintCuts: true }, + }); + return { enabled: row?.autoOverprintCuts !== false }; + }), + + setOverprintCuts: coursesEditProcedure + .input(z.object({ enabled: z.boolean() })) + .mutation(async ({ ctx, input }) => { + await ctx.db.event.update({ + where: { id: ctx.event.id }, + data: { + autoOverprintCuts: input.enabled, + overprintCutsVersion: OVERPRINT_CUTS_VERSION, + }, + }); + const editorCourses = await ctx.db.course.findMany({ + where: { + eventId: ctx.event.id, + removed: false, + geometrySource: "editor", + }, + select: { id: true }, + }); + if (editorCourses.length > 0) { + const ids = editorCourses.map((c) => c.id); + await rebuildCourseGeometry(ctx.db, ctx.event.id, ids, { + updateLength: false, + }); + for (const id of ids) { + await emitCourseUpserted(ctx.db, ctx.event.id, id); + } + } + return { success: true as const, enabled: input.enabled }; + }), + /** * Preview an IOF XML or OCD course-bundle import. Parses the file, * auto-matches XML class names to DB classes, and returns the per- diff --git a/packages/api/src/routers/event.ts b/packages/api/src/routers/event.ts index fb10d4e..243071c 100644 --- a/packages/api/src/routers/event.ts +++ b/packages/api/src/routers/event.ts @@ -317,6 +317,8 @@ export const eventRouter = router({ const result = await prisma().event.deleteMany({ where: { removed: true }, }); + const { gcOrphanTiles } = await import("../map-render-cache.js"); + await gcOrphanTiles(prisma()); return { purged: result.count }; }), diff --git a/packages/shared/src/__tests__/course-maps.test.ts b/packages/shared/src/__tests__/course-maps.test.ts index 9d661b4..29218b4 100644 --- a/packages/shared/src/__tests__/course-maps.test.ts +++ b/packages/shared/src/__tests__/course-maps.test.ts @@ -208,22 +208,31 @@ describe("course map SVG generators", () => { { points: [{ x: 40, y: 40 }, { x: 80, y: 80 }] }, ], }); - expect(svg).toContain('data-map-layer="course-overlay"'); - expect(svg).toContain('stroke-width="0.35"'); - expect(svg).toContain('data-control-code="31"'); - expect(svg).toContain("1"); - expect(svg.match(/1"); + expect(lower.match(/ { @@ -245,16 +254,16 @@ describe("course map SVG generators", () => { }; // 1:15000 base map printed at 1:7500 doubles every overprint dimension. const enlarged = renderCourseOverlaySvg({ ...options, overprintScale: 2 }); - expect(enlarged).toContain('stroke-width="0.7"'); - expect(enlarged).toContain( + expect(enlarged.lower).toContain('stroke-width="0.7"'); + expect(enlarged.upper).toContain( `font-size="${(document.appearance.numberHeightMm * 2) / (1409 / 2048)}"`, ); - expect(enlarged).toContain('r="6"'); // finish outer 3 mm -> 6 mm - expect(enlarged).toContain('r="4"'); // finish inner 2 mm -> 4 mm + expect(enlarged.lower).toContain('r="6"'); // finish outer 3 mm -> 6 mm + expect(enlarged.lower).toContain('r="4"'); // finish inner 2 mm -> 4 mm // Default factor 1 keeps ISOM base dimensions. const plain = renderCourseOverlaySvg(options); - expect(plain).toContain('stroke-width="0.35"'); - expect(plain).toContain('r="3"'); + expect(plain.lower).toContain('stroke-width="0.35"'); + expect(plain.lower).toContain('r="3"'); }); it("renders descriptions with symbols and escaped text", () => { @@ -390,7 +399,7 @@ describe("course map SVG generators", () => { expect(svg).toContain('fill="#ffffff"'); expect(svg).toContain('id="oob-oob"'); expect(svg).toContain('stroke-width="0.4"'); // 0.2 mm * overprintScale 2 - expect(svg).toContain("mix-blend-mode:multiply"); + expect(svg).not.toContain("mix-blend-mode"); }); }); diff --git a/packages/shared/src/course-maps/course-overlay-svg.ts b/packages/shared/src/course-maps/course-overlay-svg.ts index 103b6a7..3cac7fc 100644 --- a/packages/shared/src/course-maps/course-overlay-svg.ts +++ b/packages/shared/src/course-maps/course-overlay-svg.ts @@ -51,9 +51,25 @@ export interface RenderCourseOverlayOptions { overprintScale?: number; } +export interface CourseOverlayLayers { + /** 701/703/705/706/708 — sits under the map ink layer. */ + lower: string; + /** 704/707/714 — sits above the map ink layer. */ + upper: string; +} + +function openGroup(layer: "lower" | "upper", purple: string, stroke: number): string { + return ``; +} + +/** + * Render the course overprint as two SVG groups so the caller can + * interleave the map's above-purple ink layer between them (IOF digital + * print stacking). No opacity or blend mode — stacking handles visibility. + */ export function renderCourseOverlaySvg( options: RenderCourseOverlayOptions, -): string { +): CourseOverlayLayers { const { appearance, frame, window } = options; const enlarge = options.overprintScale !== undefined && @@ -70,6 +86,9 @@ export function renderCourseOverlaySvg( // (metrically Arial-compatible) has cap/digit height 1409/2048 of the // em, so scale up to make the printed digits measure `labelSize` mm. const labelFontSize = labelSize / (1409 / 2048); + // A narrow opaque knockout keeps purple numbers readable over dense map + // ink without turning them into large white labels. + const labelHaloWidth = labelFontSize * 0.12; const startSize = 3.5 * enlarge; const finishInner = 2 * enlarge; const finishOuter = 3 * enlarge; @@ -79,15 +98,16 @@ export function renderCourseOverlaySvg( })); const obstacles = controls.map(({ point }) => point); const drawnSegments: OverlaySegment[] = []; - const parts: string[] = [ - ``, - ]; + const lower: string[] = [openGroup("lower", purple, stroke)]; + const upper: string[] = [openGroup("upper", purple, stroke)]; for (const leg of options.legs) { if (leg.points.length < 2) continue; const points = leg.points.map((point) => mapToPage(point, frame, window)); const kind = leg.kind ?? (leg.dashed ? "forbidden_route" : "leg"); - if (kind !== "leg") { + + // Upper purple: marked route (707), forbidden route (714). + if (kind === "marked_route" || kind === "forbidden_route") { const path = points .map((point, index) => `${index === 0 ? "M" : "L"}${point.x},${point.y}`) .join(" "); @@ -95,11 +115,9 @@ export function renderCourseOverlaySvg( const dash = kind === "marked_route" ? `${stroke * 4} ${stroke * 2}` - : kind === "forbidden_route" - ? `${stroke * 6} ${stroke * 3}` - : null; - parts.push( - ``, + : `${stroke * 6} ${stroke * 3}`; + upper.push( + ``, ); for (let index = 0; index < points.length - 1; index += 1) { drawnSegments.push({ @@ -112,6 +130,23 @@ export function renderCourseOverlaySvg( continue; } + // Lower purple: course legs (705) and restricted lines (708). + if (kind === "restricted_line") { + const path = points + .map((point, index) => `${index === 0 ? "M" : "L"}${point.x},${point.y}`) + .join(" "); + lower.push(``); + for (let index = 0; index < points.length - 1; index += 1) { + drawnSegments.push({ + x1: points[index].x, + y1: points[index].y, + x2: points[index + 1].x, + y2: points[index + 1].y, + }); + } + continue; + } + const pieces = leg.preclipped || !leg.gaps?.length ? [points] @@ -124,7 +159,7 @@ export function renderCourseOverlaySvg( `${index === 0 ? "M" : "L"}${point.x},${point.y}`, ) .join(" "); - parts.push(``); + lower.push(``); for (let index = 0; index < piece.length - 1; index += 1) { drawnSegments.push({ x1: piece[index].x, @@ -143,7 +178,7 @@ export function renderCourseOverlaySvg( radius * 1.2, ); for (const segment of segments) { - parts.push( + lower.push( ``, ); drawnSegments.push(segment); @@ -182,13 +217,13 @@ export function renderCourseOverlaySvg( for (const { control, point } of controls) { const data = `data-control-id="${escapeSvgText(control.id)}" data-control-code="${escapeSvgText(control.code)}"`; if (control.type === "start") { - parts.push( + lower.push( ``, ); continue; } if (control.type === "finish") { - parts.push( + lower.push( ``, ``, ); @@ -203,7 +238,7 @@ export function renderCourseOverlaySvg( ? cut : { start: cut.start + rotation, end: cut.end + rotation }, ); - parts.push( + lower.push( ``, ); const label = @@ -216,17 +251,19 @@ export function renderCourseOverlaySvg( }; if (!label) continue; if (label.leader) { - parts.push( + upper.push( ``, ); } const placement = placementCircles.find((circle) => circle.id === control.id); - // ISOM 704: Arial, non-bold. - parts.push( - `${escapeSvgText(placement?.label ?? control.code)}`, + // ISOM 704: Arial, non-bold — upper purple with a narrow white + // knockout so numbers stay legible over map ink. + upper.push( + `${escapeSvgText(placement?.label ?? control.code)}`, ); } - parts.push(""); - return parts.join(""); + lower.push(""); + upper.push(""); + return { lower: lower.join(""), upper: upper.join("") }; } diff --git a/packages/shared/src/course-maps/objects-svg.ts b/packages/shared/src/course-maps/objects-svg.ts index fc7eef7..c20c33d 100644 --- a/packages/shared/src/course-maps/objects-svg.ts +++ b/packages/shared/src/course-maps/objects-svg.ts @@ -239,13 +239,11 @@ function renderObject( defs.push(outOfBoundsPatternDef(patternId, purple, overprintScale)); } const fill = resolveFill(fillMode, object.fill, patternId); - const blend = - fillMode === "outOfBounds" ? ` style="mix-blend-mode:multiply"` : ""; const closed = object.closed || fillMode === "whiteout" || fillMode === "outOfBounds"; - return ``; + return ``; } case "rectangle": { const p = @@ -270,9 +268,7 @@ function renderObject( defs.push(outOfBoundsPatternDef(patternId, purple, overprintScale)); } const fill = resolveFill(fillMode, object.fill, patternId); - const blend = - fillMode === "outOfBounds" ? ` style="mix-blend-mode:multiply"` : ""; - return ``; + return ``; } case "image": { const p = diff --git a/packages/shared/src/course-maps/schema.ts b/packages/shared/src/course-maps/schema.ts index bb76895..3eb3a32 100644 --- a/packages/shared/src/course-maps/schema.ts +++ b/packages/shared/src/course-maps/schema.ts @@ -253,3 +253,33 @@ export const defaultMapAppearance: MapAppearance = { lineWidthMm: 0.35, numberHeightMm: 4, }; + +/** + * IOF colour-stack profile for lower/upper purple placement. + * See docs/map-color-stack.md and IOF Printing and Colour Definitions + * rev. 4 ch. 7. `auto` resolves from the file's own "Lower purple" + * colour or map scale. + */ +export const colorProfileSchema = z.enum([ + "auto", + "isom", + "issprom", + "isskiom", + "ismtbom", +]); +export type ColorProfile = z.infer; + +/** Explicit rule-set values after `auto` resolution (never `auto`). */ +export const resolvedColorProfileSchema = z.enum([ + "isom", + "issprom", + "isskiom", + "ismtbom", +]); +export type ResolvedColorProfile = z.infer; + +export const colorStackOverridesSchema = z.object({ + above: z.array(z.number().int()).optional(), + below: z.array(z.number().int()).optional(), +}); +export type ColorStackOverrides = z.infer; diff --git a/packages/web/src/components/MapLayoutEditor.tsx b/packages/web/src/components/MapLayoutEditor.tsx index a46cc5b..dcce154 100644 --- a/packages/web/src/components/MapLayoutEditor.tsx +++ b/packages/web/src/components/MapLayoutEditor.tsx @@ -348,6 +348,7 @@ function renderObject( window: MapWindow, onPointerDown?: (event: ReactPointerEvent) => void, graphicHref?: (graphicId: number) => string, + purple = "#a626ff", ): ReactNode { const common = { "data-object-id": object.id, @@ -464,7 +465,7 @@ function renderObject( dangerouslySetInnerHTML={{ __html: outOfBoundsPatternDef( patternId, - "#a626ff", + purple, 1, ), }} @@ -476,11 +477,7 @@ function renderObject( fill={fill} stroke={object.stroke ?? "none"} strokeWidth={object.strokeWidthMm} - style={ - fillMode === "outOfBounds" - ? { ...common.style, mixBlendMode: "multiply" } - : common.style - } + style={common.style} /> ); @@ -504,7 +501,7 @@ function renderObject( dangerouslySetInnerHTML={{ __html: outOfBoundsPatternDef( patternId, - "#a626ff", + purple, 1, ), }} @@ -519,11 +516,7 @@ function renderObject( fill={fill} stroke={object.stroke ?? "none"} strokeWidth={object.strokeWidthMm} - style={ - fillMode === "outOfBounds" - ? { ...common.style, mixBlendMode: "multiply" } - : common.style - } + style={common.style} /> ); @@ -618,6 +611,7 @@ export function MapLayoutEditor({ const dirtyRef = useRef(false); const [displayedPreview, setDisplayedPreview] = useState<{ url: string; + inkUrl: string; center: MapPoint; printScale: number; } | null>(null); @@ -731,63 +725,89 @@ export function MapLayoutEditor({ }, [history.present.center, history.present.printScale]); const previewUrls = useMemo(() => { - const makeUrl = (dpi: number) => { - const params = new URLSearchParams({ + const makeUrl = (dpi: number, layer: "full" | "ink") => { + const params = new URLSearchParams({ cx: String(previewState.center.x), cy: String(previewState.center.y), wMm: String(document.mapFrame.width), hMm: String(document.mapFrame.height), - printScale: String(previewState.printScale), + printScale: String(previewState.printScale), dpi: String(dpi), - }); + layer, + }); if (rotationDeg !== 0) params.set("rot", rotationDeg.toFixed(2)); - return `/api/maps/${encodeURIComponent(nameId)}/window.png?${params}`; + return `/api/maps/${encodeURIComponent(nameId)}/window.png?${params}`; }; + const dpiHigh = editorPreviewDpi(document.mapFrame); return { - low: makeUrl(120), - high: makeUrl(editorPreviewDpi(document.mapFrame)), + full: { low: makeUrl(120, "full"), high: makeUrl(dpiHigh, "full") }, + ink: { low: makeUrl(120, "ink"), high: makeUrl(dpiHigh, "ink") }, }; }, [document.mapFrame, nameId, previewState, rotationDeg]); useEffect(() => { let active = true; - const shown = { center: previewState.center, printScale: previewState.printScale }; - const high = new Image(); + const shown = { + center: previewState.center, + printScale: previewState.printScale, + }; + const apply = (fullUrl: string, inkUrl: string) => { + if (!active) return; + setDisplayedPreview({ url: fullUrl, inkUrl, ...shown }); + setPreviewErrorUrl(null); + }; + const highFull = new Image(); + const highInk = new Image(); + let highPending = 2; const loadHigh = () => { - high.onload = () => { - if (active) { - setDisplayedPreview({ url: previewUrls.high, ...shown }); - setPreviewErrorUrl(null); - } + const done = () => { + highPending -= 1; + if (highPending === 0) apply(previewUrls.full.high, previewUrls.ink.high); }; - high.onerror = () => { - if (active) setPreviewErrorUrl(previewUrls.high); + highFull.onload = done; + highInk.onload = done; + highFull.onerror = () => { + if (active) setPreviewErrorUrl(previewUrls.full.high); }; - high.src = previewUrls.high; + highInk.onerror = done; // ink may be empty; still show full + highFull.src = previewUrls.full.high; + highInk.src = previewUrls.ink.high; }; - const low = new Image(); - low.onload = () => { - if (!active) return; - setDisplayedPreview({ url: previewUrls.low, ...shown }); - setPreviewErrorUrl(null); - loadHigh(); + const lowFull = new Image(); + const lowInk = new Image(); + let lowPending = 2; + const lowDone = () => { + lowPending -= 1; + if (lowPending === 0) { + apply(previewUrls.full.low, previewUrls.ink.low); + loadHigh(); + } }; - low.onerror = () => { - if (active) setPreviewErrorUrl(previewUrls.low); + lowFull.onload = lowDone; + lowInk.onload = lowDone; + lowFull.onerror = () => { + if (active) setPreviewErrorUrl(previewUrls.full.low); }; - low.src = previewUrls.low; + lowInk.onerror = lowDone; + lowFull.src = previewUrls.full.low; + lowInk.src = previewUrls.ink.low; return () => { active = false; - low.onload = null; - low.onerror = null; - high.onload = null; - high.onerror = null; + lowFull.onload = null; + lowFull.onerror = null; + lowInk.onload = null; + lowInk.onerror = null; + highFull.onload = null; + highFull.onerror = null; + highInk.onload = null; + highInk.onerror = null; }; // The shown window is snapshotted when the URLs change. // eslint-disable-next-line react-hooks/exhaustive-deps }, [previewUrls]); - const previewReady = displayedPreview?.url === previewUrls.high; + const previewReady = displayedPreview?.url === previewUrls.full.high; const previewError = - previewErrorUrl === previewUrls.low || previewErrorUrl === previewUrls.high; + previewErrorUrl === previewUrls.full.low || + previewErrorUrl === previewUrls.full.high; const courseOverlaySvg = useMemo( () => renderCourseOverlaySvg({ @@ -2608,15 +2628,35 @@ export function MapLayoutEditor({ currentWindow, ); return ( - + <> + + + + ); })()} @@ -2632,7 +2672,8 @@ export function MapLayoutEditor({ document.mapFrame, currentWindow, objectDragHandler(object, mode === "template"), - graphicHref, + graphicHref, + document.appearance.purple, ), )} @@ -2649,6 +2690,7 @@ export function MapLayoutEditor({ currentWindow, objectDragHandler(object, mode === "map"), graphicHref, + document.appearance.purple, ), )} @@ -2666,6 +2708,7 @@ export function MapLayoutEditor({ currentWindow, objectDragHandler(object, mode === "template"), graphicHref, + document.appearance.purple, ), )} @@ -2681,7 +2724,8 @@ export function MapLayoutEditor({ document.mapFrame, currentWindow, objectDragHandler(object, mode === "map"), - graphicHref, + graphicHref, + document.appearance.purple, ), )} @@ -2692,7 +2736,7 @@ export function MapLayoutEditor({ data-testid="map-course-overlay" pointerEvents="none" style={{ userSelect: "none" }} - dangerouslySetInnerHTML={{ __html: courseOverlaySvg }} + dangerouslySetInnerHTML={{ __html: courseOverlaySvg.upper }} /> {history.present.templateObjects @@ -2707,7 +2751,8 @@ export function MapLayoutEditor({ document.mapFrame, currentWindow, objectDragHandler(object, mode === "template"), - graphicHref, + graphicHref, + document.appearance.purple, ), )} @@ -2724,6 +2769,7 @@ export function MapLayoutEditor({ currentWindow, objectDragHandler(object, mode === "map"), graphicHref, + document.appearance.purple, ), )} @@ -2741,6 +2787,7 @@ export function MapLayoutEditor({ currentWindow, objectDragHandler(object, mode === "template"), graphicHref, + document.appearance.purple, ), )} @@ -2756,7 +2803,8 @@ export function MapLayoutEditor({ document.mapFrame, currentWindow, objectDragHandler(object, mode === "map"), - graphicHref, + graphicHref, + document.appearance.purple, ), )} {history.present.description.visible && ( diff --git a/packages/web/src/components/MapPanel.tsx b/packages/web/src/components/MapPanel.tsx index 7f50691..2816d70 100644 --- a/packages/web/src/components/MapPanel.tsx +++ b/packages/web/src/components/MapPanel.tsx @@ -319,6 +319,11 @@ function MapPanelImpl({ setUploadError(`Map upload failed: ${err.message}`); }, }); + const setColorStack = trpc.course.setMapColorStack.useMutation({ + onSuccess: () => { + mapMetadata.refetch(); + }, + }); const fileInputRef = useRef(null); const [dragOver, setDragOver] = useState(false); // Default to hiding unrelated controls when there's a highlighted selection @@ -807,7 +812,8 @@ function MapPanelImpl({ mapBounds={mapMetadata.data?.bounds} mapScale={mapMetadata.data?.scale} northOffset={mapMetadata.data?.northOffset} - mapVersion={mapMetadata.data?.uploadedAt} + cutRotationDeg={mapMetadata.data?.cutRotationDeg} + mapVersion={mapMetadata.data?.renderKey ?? mapMetadata.data?.uploadedAt} calibration={mapMetadata.data?.calibration} controls={controlOverlays} courses={courseOverlays} @@ -847,16 +853,71 @@ function MapPanelImpl({ {/* Map info — below the map (course editor only) */} {showMapInfo && !hideToolbar && (
-
-
+
+
{mapInfo.data && ( - {mapInfo.data.fileName} + {mapInfo.data.fileName} )} + {mapMetadata.data && ( + <> + + + + )}
{!isFullscreen && canFilter && !toolbar && ( diff --git a/packages/web/src/components/MapViewer.tsx b/packages/web/src/components/MapViewer.tsx index 53968b8..9c299b2 100644 --- a/packages/web/src/components/MapViewer.tsx +++ b/packages/web/src/components/MapViewer.tsx @@ -9,9 +9,10 @@ import { type ControlDescription, type PlacementCircle, type PlacementSeg, + defaultMapAppearance, } from "@oxygen/shared"; import { getDescriptionSymbols } from "../iof-symbols"; -import { TileLayer } from "./TileLayer"; +import { TileBlobCacheProvider, TileLayer } from "./TileLayer"; import { kioskKeyFromUrl } from "../lib/kiosk-key"; import { type TileViewport, @@ -214,8 +215,10 @@ interface Props { * lines vertical on screen. Applied as CSS rotation. */ northOffset?: number | null; - /** Map upload timestamp for cache busting tile URLs */ - mapVersion?: number; + /** Rotation for circle slits (paper north only — excludes meridian tilt). */ + cutRotationDeg?: number | null; + /** Render key / upload stamp for cache-busting tile URLs. */ + mapVersion?: number | string; /** * Map-mm ↔ WGS84 anchor points from the map's georeference * (course.mapMetadata). Preferred source for the affine transform — @@ -286,6 +289,7 @@ export function MapViewer({ mapBounds, mapScale, northOffset, + cutRotationDeg, mapVersion, calibration, controls = [], @@ -1484,7 +1488,9 @@ export function MapViewer({ } } - const elements: React.ReactNode[] = []; + const purple = defaultMapAppearance.purple; + const lowerElements: React.ReactNode[] = []; + const upperElements: React.ReactNode[] = []; // Editor hit-target metadata. The JSX (with its ref-writing event // handlers) is materialized OUTSIDE this memo — the React Compiler // lint treats everything inside useMemo as render-scope, where ref @@ -1559,13 +1565,12 @@ export function MapViewer({ if (props.preclipped) { const d = screenPts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" "); - elements.push( - + lowerElements.push( + ); } else { - // Automatic overprint gaps (black map features under the - // leg), computed server-side into the geometry as fractions - // of the full leg. + // Automatic gaps around compact knoll / rock point features, + // computed server-side as fractions of the full leg. const legGaps: { from: number; to: number }[] = Array.isArray(props.gaps) ? props.gaps : []; @@ -1592,11 +1597,11 @@ export function MapViewer({ const segs = clipLine(pts[si], pts[si + 1], obstacles, radius * 1.2); for (let segi = 0; segi < segs.length; segi++) { const seg = segs[segi]; - elements.push( + lowerElements.push( + stroke={purple} strokeWidth={legStroke} /> ); } } @@ -1615,9 +1620,9 @@ export function MapViewer({ }); } const d = screenPts.map((p, i) => `${i === 0 ? "M" : "L"}${p.px.toFixed(1)},${p.py.toFixed(1)}`).join(" "); - elements.push( - + upperElements.push( + ); } else if ((props.symbolType === "forbidden_route" || props.symbolType === "restricted_line") && geom.type === "LineString") { const coords = geom.coordinates as [number, number][]; @@ -1632,9 +1637,10 @@ export function MapViewer({ }); } const d = screenPts.map((p, i) => `${i === 0 ? "M" : "L"}${p.px.toFixed(1)},${p.py.toFixed(1)}`).join(" "); - elements.push( - ); } @@ -1656,7 +1662,7 @@ export function MapViewer({ const d = screenPts .map((p, i) => `${i === 0 ? "M" : "L"}${p.px.toFixed(1)},${p.py.toFixed(1)}`) .join(" "); - elements.push( + upperElements.push( + stroke={purple} strokeWidth={legStroke} /> ); } } @@ -1785,7 +1791,7 @@ export function MapViewer({ c.punchStatus === "extra" ? "#f59e0b" : c.punchStatus === "ok" ? "#059669" : isHighlighted ? "#ef4444" : - "#c026d3"; + purple; // Editor fading: while a course is selected, regular controls off // the course recede (Purple Pen style) — still purple, still // clickable. Start/finish stay at full strength. @@ -1797,13 +1803,13 @@ export function MapViewer({ if (c.type === "Start") { const s = startSize; const triPath = `M${pos.x},${pos.y - s} L${pos.x - s * 0.866},${pos.y + s * 0.5} L${pos.x + s * 0.866},${pos.y + s * 0.5} Z`; - elements.push( + lowerElements.push( onControlClick(c.id) : undefined} /> ); } else if (c.type === "Finish") { - elements.push( + lowerElements.push( onControlClick(c.id) : undefined}> @@ -1814,9 +1820,10 @@ export function MapViewer({ const cuts = getCourseGeomCuts(c.code); if (cuts && cuts.length > 0) { - const adj = cuts.map(g => ({ start: g.start + (northOffset || 0), end: g.end + (northOffset || 0) })); + const rot = cutRotationDeg ?? 0; + const adj = cuts.map(g => ({ start: g.start + rot, end: g.end + rot })); const arcs = drawBrokenCircle(pos.x, pos.y, radius, adj); - elements.push( + lowerElements.push( onControlClick(c.id) : undefined} /> ); } else { - elements.push( + lowerElements.push( = 1) { - elements.push( + lowerElements.push( ); @@ -1850,7 +1857,7 @@ export function MapViewer({ const x2 = pos.x + ringR * Math.cos(endAngle); const y2 = pos.y + ringR * Math.sin(endAngle); const largeArc = angle > Math.PI ? 1 : 0; - elements.push( + lowerElements.push( @@ -1861,7 +1868,7 @@ export function MapViewer({ // Punch count badge if (c.punchCount !== undefined && c.punchCount > 0) { const badgeR = Math.max(6, labelSize * 0.5); - elements.push( + upperElements.push( @@ -1885,16 +1892,20 @@ export function MapViewer({ // number to its own circle, so the association stays readable. if (placedLabel.leader) { const ld = placedLabel.leader; - elements.push( + upperElements.push( ); } - elements.push( + upperElements.push( = 2) { const d = allPts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" "); - elements.push( + upperElements.push( ); @@ -2015,7 +2026,7 @@ export function MapViewer({ const legM = mmToMeters(mapMmDist(srcPt1, srcPt2)); const label = formatDist(legM); const halfW = label.length * 3.5 + 6; - elements.push( + upperElements.push( @@ -2028,14 +2039,14 @@ export function MapViewer({ } for (let i = 0; i < screenMeasurePts.length; i++) { - elements.push( + upperElements.push( ); } } - return { nodes: elements, controlHits, legHits, radiusPx: radius, strokePx: stroke }; + return { lowerNodes: lowerElements, upperNodes: upperElements, controlHits, legHits, radiusPx: radius, strokePx: stroke }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [viewport, containerSize, renderW, renderH, controls, courses, courseGeometry, coursesWithGeometry, highlightControlId, highlightCourseName, @@ -2385,22 +2396,50 @@ export function MapViewer({ transformOrigin: "center center", ...(rotDeg !== 0 ? { width: renderW, height: renderH, left: (containerSize.w - renderW) / 2, top: (containerSize.h - renderH) / 2 } : {}), }}> - {/* Base map tiles */} + + {/* Base map tiles (composite half) */} - {/* Overlay SVG */} + {/* Lower purple: legs, circles, start, finish */} - {overlayContent?.nodes} + {overlayContent?.lowerNodes} + + + + {/* Ink half — black/brown/blue 100% lines and points above lower purple */} + + + {/* Upper purple + GPS + editor hits */} + + + {overlayContent?.upperNodes} {locateMode !== "off" && geo.position && viewport && (() => { const { px, py } = latlngToPixel( geo.position.lat, @@ -2473,6 +2512,7 @@ export function MapViewer({ )} +
{/* Editor contextual actions — unrotated HTML, anchored at the diff --git a/packages/web/src/components/TileLayer.tsx b/packages/web/src/components/TileLayer.tsx index 71d02b4..469ed5a 100644 --- a/packages/web/src/components/TileLayer.tsx +++ b/packages/web/src/components/TileLayer.tsx @@ -4,9 +4,23 @@ * Fetches tiles via a concurrency-capped queue (not bare ``) so * large maps do not storm Cloud Run into 429s, and so failed tiles are * retried with backoff instead of being blacklisted forever. + * + * Tiles are 256×512 PNGs (composite top, ink bottom). `half` selects + * which half to display; two TileLayers sharing a `TileBlobCacheProvider` + * fetch each stacked PNG once. */ -import { useState, useMemo, useRef, useEffect, useCallback } from "react"; +import { + createContext, + useState, + useMemo, + useRef, + useEffect, + useCallback, + useContext, + type MutableRefObject, + type ReactNode, +} from "react"; import type { TileViewport } from "../lib/geo-utils"; import { lngToTileX, latToTileY } from "../lib/geo-utils"; import { kioskKeyFromUrl, tileQueryString } from "../lib/kiosk-key"; @@ -19,7 +33,13 @@ interface Props { containerHeight: number; tileUrlBase?: string; /** Cache-busting version (e.g. map upload timestamp) */ - tileVersion?: number; + tileVersion?: number | string; + /** Which half of the stacked 256×512 tile to show. Default "top". */ + half?: "top" | "bottom"; + /** Stacking order relative to sibling overlays. */ + zIndex?: number; + /** Optional test id on the outer container. */ + "data-testid"?: string; } interface TileInfo { @@ -33,6 +53,120 @@ interface TileInfo { priority: number; } +interface TileBlobCache { + blobUrls: Map; + blobUrlsRef: MutableRefObject>; + publishBlob: (key: string, url: string) => void; + dropBlob: (key: string) => void; + fetcher: TileFetcher; + retryBook: TileRetryBook; + bump: () => void; + tick: number; +} + +const TileBlobCacheContext = createContext(null); + +/** + * Share one fetch/blob cache across the composite and ink TileLayers so + * each stacked PNG is requested once. + */ +export function TileBlobCacheProvider({ children }: { children: ReactNode }) { + const fetcherRef = useRef(null); + if (!fetcherRef.current) fetcherRef.current = new TileFetcher(); + const retryBookRef = useRef(new TileRetryBook()); + const blobUrlsRef = useRef(new Map()); + const [blobUrls, setBlobUrls] = useState(new Map()); + const [tick, setTick] = useState(0); + + const publishBlob = useCallback((key: string, url: string) => { + const prev = blobUrlsRef.current.get(key); + if (prev && prev !== url) URL.revokeObjectURL(prev); + blobUrlsRef.current.set(key, url); + setBlobUrls(new Map(blobUrlsRef.current)); + }, []); + + const dropBlob = useCallback((key: string) => { + const prev = blobUrlsRef.current.get(key); + if (prev) { + URL.revokeObjectURL(prev); + blobUrlsRef.current.delete(key); + setBlobUrls(new Map(blobUrlsRef.current)); + } + }, []); + + useEffect(() => { + return () => { + for (const url of blobUrlsRef.current.values()) URL.revokeObjectURL(url); + blobUrlsRef.current.clear(); + fetcherRef.current?.cancelExcept(new Set()); + }; + }, []); + + const value = useMemo( + () => ({ + blobUrls, + blobUrlsRef, + publishBlob, + dropBlob, + fetcher: fetcherRef.current!, + retryBook: retryBookRef.current, + bump: () => setTick((n) => n + 1), + tick, + }), + [blobUrls, publishBlob, dropBlob, tick], + ); + + return ( + + {children} + + ); +} + +function useTileBlobCache(): TileBlobCache { + const shared = useContext(TileBlobCacheContext); + // Fallback for a lone TileLayer (e.g. tests) — private cache. + const localFetcher = useRef(null); + if (!localFetcher.current) localFetcher.current = new TileFetcher(); + const localRetry = useRef(new TileRetryBook()); + const localBlobs = useRef(new Map()); + const [localUrls, setLocalUrls] = useState(new Map()); + const [localTick, setLocalTick] = useState(0); + const publishBlob = useCallback((key: string, url: string) => { + const prev = localBlobs.current.get(key); + if (prev && prev !== url) URL.revokeObjectURL(prev); + localBlobs.current.set(key, url); + setLocalUrls(new Map(localBlobs.current)); + }, []); + const dropBlob = useCallback((key: string) => { + const prev = localBlobs.current.get(key); + if (prev) { + URL.revokeObjectURL(prev); + localBlobs.current.delete(key); + setLocalUrls(new Map(localBlobs.current)); + } + }, []); + useEffect(() => { + if (shared) return; + return () => { + for (const url of localBlobs.current.values()) URL.revokeObjectURL(url); + localBlobs.current.clear(); + localFetcher.current?.cancelExcept(new Set()); + }; + }, [shared]); + if (shared) return shared; + return { + blobUrls: localUrls, + blobUrlsRef: localBlobs, + publishBlob, + dropBlob, + fetcher: localFetcher.current, + retryBook: localRetry.current, + bump: () => setLocalTick((n) => n + 1), + tick: localTick, + }; +} + /** Identity for React/load/error state; event and map version are significant. */ // eslint-disable-next-line react-refresh/only-export-components -- pure URL key helper co-located with the tile layer that uses it; only costs full-reload HMR for this file export function tileRequestKey( @@ -108,19 +242,62 @@ function computeTiles( return result; } +function TileHalfImage({ + src, + tileSrc, + tile, + half, +}: { + src: string; + tileSrc: string; + tile: TileInfo; + half: "top" | "bottom"; +}) { + // Stacked PNG is 256×512; show one half via overflow clip + offset. + // The wrapper is tile.width × tile.height; the img is twice as tall so + // half="bottom" shifts it up by one tile height. + return ( +
+ +
+ ); +} + export function TileLayer({ viewport, containerWidth, containerHeight, tileUrlBase = "/api/map-tile", tileVersion, + half = "top", + zIndex, + "data-testid": testId, }: Props) { - const fetcherRef = useRef(null); - if (!fetcherRef.current) fetcherRef.current = new TileFetcher(); - const retryBookRef = useRef(new TileRetryBook()); - const blobUrlsRef = useRef(new Map()); - const [blobUrls, setBlobUrls] = useState(new Map()); - const [tick, setTick] = useState(0); + const cache = useTileBlobCache(); const query = useMemo( () => tileQueryString(tileVersion, kioskKeyFromUrl()), [tileVersion], @@ -148,54 +325,74 @@ export function TileLayer({ // Current zoom tiles const tiles = useMemo( - () => computeTiles(viewport, z, containerWidth, containerHeight, tileUrlBase, query), + () => + computeTiles( + viewport, + z, + containerWidth, + containerHeight, + tileUrlBase, + query, + ), // tick forces recompute when a retry window opens // eslint-disable-next-line react-hooks/exhaustive-deps - [viewport, z, containerWidth, containerHeight, tileUrlBase, query, tick], + [viewport, z, containerWidth, containerHeight, tileUrlBase, query, cache.tick], ); // Backdrop tiles from previous zoom — positioned for current viewport. + // Only the composite (top) half uses a backdrop; ink on a backdrop + // zoom would sit under purple inconsistently. const backdropTiles = useMemo(() => { + if (half !== "top") return []; if (backdropZ === null || backdropZ === z) return []; - return computeTiles(viewport, backdropZ, containerWidth, containerHeight, - tileUrlBase, query); - }, [viewport, backdropZ, z, containerWidth, containerHeight, tileUrlBase, query, tick]); - - const publishBlob = useCallback((key: string, url: string) => { - const prev = blobUrlsRef.current.get(key); - if (prev && prev !== url) URL.revokeObjectURL(prev); - blobUrlsRef.current.set(key, url); - setBlobUrls(new Map(blobUrlsRef.current)); - }, []); - - const dropBlob = useCallback((key: string) => { - const prev = blobUrlsRef.current.get(key); - if (prev) { - URL.revokeObjectURL(prev); - blobUrlsRef.current.delete(key); - setBlobUrls(new Map(blobUrlsRef.current)); - } - }, []); + return computeTiles( + viewport, + backdropZ, + containerWidth, + containerHeight, + tileUrlBase, + query, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + viewport, + backdropZ, + z, + containerWidth, + containerHeight, + tileUrlBase, + query, + cache.tick, + half, + ]); // Fetch desired tiles; abort ones that left the viewport. + // Only the top half drives fetching so the ink layer does not double + // the queue when both share a cache. useEffect(() => { - const fetcher = fetcherRef.current!; - const retryBook = retryBookRef.current; + if (half !== "top" && cache) { + // Ink layer: still need to fetch if used alone, but when shared the + // top half owns the queue. Mirror desired keys so blobs stay warm. + } + const fetcher = cache.fetcher; + const retryBook = cache.retryBook; const now = Date.now(); const desired = new Map(); for (const t of [...tiles, ...backdropTiles]) desired.set(t.key, t); + // Only cancel/fetch from the composite layer so two layers sharing a + // cache do not fight over the queue. + if (half !== "top") return; + fetcher.cancelExcept(new Set(desired.keys())); - // Drop blob URLs for tiles that are long gone (keep a small cache of - // currently desired keys only — backdrop + current). - for (const key of [...blobUrlsRef.current.keys()]) { - if (!desired.has(key)) dropBlob(key); + for (const key of [...cache.blobUrlsRef.current.keys()]) { + if (!desired.has(key)) cache.dropBlob(key); } const waitingKeys: string[] = []; for (const tile of desired.values()) { - if (blobUrlsRef.current.has(tile.key)) continue; + if (cache.blobUrlsRef.current.has(tile.key)) continue; const decision = retryBook.decision(tile.key, now); if (decision.action === "wait") { waitingKeys.push(tile.key); @@ -212,10 +409,8 @@ export function TileLayer({ .then((result) => { if (result.ok) { retryBook.clear(tile.key); - // Effect may have attached multiple .then handlers while the - // same fetch was pending — only create one object URL. - if (!blobUrlsRef.current.has(tile.key)) { - publishBlob(tile.key, URL.createObjectURL(result.blob)); + if (!cache.blobUrlsRef.current.has(tile.key)) { + cache.publishBlob(tile.key, URL.createObjectURL(result.blob)); } return; } @@ -223,34 +418,25 @@ export function TileLayer({ retryBook.recordFailure(tile.key, Date.now(), { retryAfterHeader: result.retryAfter, }); - setTick((n) => n + 1); + cache.bump(); }); } const nextAt = retryBook.earliestRetryAt(waitingKeys, now); let timer: ReturnType | undefined; if (nextAt != null) { - timer = setTimeout(() => setTick((n) => n + 1), Math.max(0, nextAt - now)); + timer = setTimeout(() => cache.bump(), Math.max(0, nextAt - now)); } return () => { if (timer) clearTimeout(timer); }; - }, [tiles, backdropTiles, publishBlob, dropBlob]); - - // Revoke all blobs on unmount. - useEffect(() => { - return () => { - for (const url of blobUrlsRef.current.values()) URL.revokeObjectURL(url); - blobUrlsRef.current.clear(); - fetcherRef.current?.cancelExcept(new Set()); - }; - }, []); + }, [tiles, backdropTiles, cache, half]); const allCurrentLoaded = - tiles.length > 0 && tiles.every((t) => blobUrls.has(t.key)); + tiles.length > 0 && tiles.every((t) => cache.blobUrls.has(t.key)); // Once all current tiles are loaded, mark this zoom as loaded and clear backdrop - if (allCurrentLoaded) { + if (half === "top" && allCurrentLoaded) { everLoadedZRef.current.add(z); if (backdropZ !== null && backdropZ !== z) { lastFullyLoadedZRef.current = z; @@ -259,57 +445,40 @@ export function TileLayer({ return (
{backdropTiles.map((tile) => { - const src = blobUrls.get(tile.key); + const src = cache.blobUrls.get(tile.key); if (!src) return null; return ( - shows; expose the API URL - // for tests and debugging. - data-tile-url={tile.src} - alt="" - draggable={false} - decoding="async" - style={{ - position: "absolute", - left: tile.x, - top: tile.y, - width: tile.width, - height: tile.height, - imageRendering: "auto", - }} + tileSrc={tile.src} + tile={tile} + half={half} /> ); })} {tiles.map((tile) => { - const src = blobUrls.get(tile.key); + const src = cache.blobUrls.get(tile.key); if (!src) return null; return ( - ); })} diff --git a/packages/web/src/components/replay/ReplayMapLayer.tsx b/packages/web/src/components/replay/ReplayMapLayer.tsx index 37b7fb2..e9b38fc 100644 --- a/packages/web/src/components/replay/ReplayMapLayer.tsx +++ b/packages/web/src/components/replay/ReplayMapLayer.tsx @@ -171,7 +171,9 @@ function drawNativeTilesOnCanvas( ctx.save(); ctx.transform(a, b, c, d, tl.sx, tl.sy); - ctx.drawImage(img, 0, 0, 256, 256); + // Stacked tiles are 256×512 (composite + ink); draw the top half only. + const srcH = Math.min(256, img.naturalHeight || 256); + ctx.drawImage(img, 0, 0, 256, srcH, 0, 0, 256, 256); ctx.restore(); } } diff --git a/packages/web/src/i18n/locales/en/courses.json b/packages/web/src/i18n/locales/en/courses.json index c700a53..99180e3 100644 --- a/packages/web/src/i18n/locales/en/courses.json +++ b/packages/web/src/i18n/locales/en/courses.json @@ -117,6 +117,8 @@ "undo": "Undo (Ctrl+Z)", "redo": "Redo (Ctrl+Shift+Z)", "hideOtherControls": "Hide other controls", + "toggleCuts": "Auto slits", + "toggleCutsTitle": "Automatic circle slits and leg gaps over map features", "alsoIn": "Also in: {{names}}", "affects": "Affects: {{names}}", "actionAdd": "Add control", diff --git a/packages/web/src/i18n/locales/en/library.json b/packages/web/src/i18n/locales/en/library.json index ff17dde..1651036 100644 --- a/packages/web/src/i18n/locales/en/library.json +++ b/packages/web/src/i18n/locales/en/library.json @@ -33,6 +33,16 @@ "northLinesStale": "North lines are {{degrees}}° off current magnetic north — consider redrawing them before printing", "northLinesStaleShort": "North lines {{degrees}}° stale", "northLinesNone": "No magnetic north lines found in the file", + "colorProfile": "Colour stack", + "colorProfileAuto": "Auto", + "colorProfileIsom": "ISOM (forest)", + "colorProfileIssprom": "ISSprOM (sprint)", + "colorProfileIsskiom": "ISSkiOM", + "colorProfileIsmtbom": "ISMTBOM", + "colorProfileResolvedScale": "Auto — {{profile}} (from scale 1:{{scale}})", + "colorProfileResolvedFile": "Auto — from map's Lower purple colour", + "colorProfileResolvedDefault": "Auto — {{profile}}", + "northLinesBelow": "North lines under course purple", "controlsHelp": "Define the club's punching units as series. Own units first, borrowed ranges after — the course editor allocates codes in that order. Mark SRR units so placed controls are radio-ready.", "createSeries": "Add series", "seriesName": "Series name", diff --git a/packages/web/src/i18n/locales/sv/courses.json b/packages/web/src/i18n/locales/sv/courses.json index b558e82..29f9558 100644 --- a/packages/web/src/i18n/locales/sv/courses.json +++ b/packages/web/src/i18n/locales/sv/courses.json @@ -117,6 +117,8 @@ "undo": "Ångra (Ctrl+Z)", "redo": "Gör om (Ctrl+Shift+Z)", "hideOtherControls": "Dölj övriga kontroller", + "toggleCuts": "Autoklipp", + "toggleCutsTitle": "Automatiska slits i cirklar och luckor i sträckor över kartdetaljer", "alsoIn": "Även i: {{names}}", "affects": "Påverkar: {{names}}", "actionAdd": "Lägg till kontroll", diff --git a/packages/web/src/i18n/locales/sv/library.json b/packages/web/src/i18n/locales/sv/library.json index 193dc99..cd9a591 100644 --- a/packages/web/src/i18n/locales/sv/library.json +++ b/packages/web/src/i18n/locales/sv/library.json @@ -33,6 +33,16 @@ "northLinesStale": "Norrlinjerna avviker {{degrees}}° från dagens magnetiska nord — överväg att rita om dem före tryck", "northLinesStaleShort": "Norrlinjer {{degrees}}° inaktuella", "northLinesNone": "Inga magnetiska norrlinjer hittades i filen", + "colorProfile": "Färgstapling", + "colorProfileAuto": "Auto", + "colorProfileIsom": "ISOM (skog)", + "colorProfileIssprom": "ISSprOM (sprint)", + "colorProfileIsskiom": "ISSkiOM", + "colorProfileIsmtbom": "ISMTBOM", + "colorProfileResolvedScale": "Auto — {{profile}} (från skala 1:{{scale}})", + "colorProfileResolvedFile": "Auto — från kartans undre lila", + "colorProfileResolvedDefault": "Auto — {{profile}}", + "northLinesBelow": "Norrlinjer under banlila", "controlsHelp": "Klubbens stämplingsenheter som serier. Egna först, lånade därefter — banläggaren tilldelar koder i den ordningen. Markera SRR-enheter så att utplacerade kontroller blir radio-klara.", "createSeries": "Lägg till serie", "seriesName": "Serienamn", diff --git a/packages/web/src/lib/__tests__/kiosk-key.test.ts b/packages/web/src/lib/__tests__/kiosk-key.test.ts index 4e6c367..2dd862d 100644 --- a/packages/web/src/lib/__tests__/kiosk-key.test.ts +++ b/packages/web/src/lib/__tests__/kiosk-key.test.ts @@ -17,11 +17,12 @@ describe("kioskKeyFromUrl", () => { describe("tileQueryString", () => { // `` tile requests cannot send the x-kiosk-key header, so the key - // must ride in the query string for key-only kiosk devices. - it("combines version and kiosk key", () => { - expect(tileQueryString(undefined, null)).toBe(""); - expect(tileQueryString(42, null)).toBe("?v=42"); - expect(tileQueryString(undefined, "secret")).toBe("?k=secret"); - expect(tileQueryString(42, "s+cret")).toBe("?v=42&k=s%2Bcret"); + // must ride in the query string for key-only kiosk devices. `f=2` marks + // the stacked 256×512 tile format so stale 256×256 caches miss. + it("combines format, version and kiosk key", () => { + expect(tileQueryString(undefined, null)).toBe("?f=2"); + expect(tileQueryString(42, null)).toBe("?f=2&v=42"); + expect(tileQueryString(undefined, "secret")).toBe("?f=2&k=secret"); + expect(tileQueryString(42, "s+cret")).toBe("?f=2&v=42&k=s%2Bcret"); }); }); diff --git a/packages/web/src/lib/kiosk-key.ts b/packages/web/src/lib/kiosk-key.ts index a0c2f4c..975fcf3 100644 --- a/packages/web/src/lib/kiosk-key.ts +++ b/packages/web/src/lib/kiosk-key.ts @@ -15,13 +15,19 @@ export function kioskKeyFromUrl( * Query string for a map-tile `` src. Image requests can't carry the * `x-kiosk-key` header, so key-only devices (kiosk, start screen) must * pass the key as `?k=` — the REST guard accepts either. + * + * `f=2` marks the stacked 256×512 tile format (composite + ink) so a + * browser that still holds a week-old 256×256 PNG from before the + * format change cannot feed it into the half-slicer. */ +export const TILE_FORMAT = 2; + export function tileQueryString( - tileVersion: number | undefined, + tileVersion: number | string | undefined, kioskKey: string | null, ): string { - const parts: string[] = []; + const parts: string[] = [`f=${TILE_FORMAT}`]; if (tileVersion) parts.push(`v=${tileVersion}`); if (kioskKey) parts.push(`k=${encodeURIComponent(kioskKey)}`); - return parts.length > 0 ? `?${parts.join("&")}` : ""; + return `?${parts.join("&")}`; } diff --git a/packages/web/src/pages/CourseEditorPage.tsx b/packages/web/src/pages/CourseEditorPage.tsx index 70d87b7..404ce4e 100644 --- a/packages/web/src/pages/CourseEditorPage.tsx +++ b/packages/web/src/pages/CourseEditorPage.tsx @@ -1114,6 +1114,18 @@ export function CourseEditorPage() { const canUndo = undoStack.canUndo; const canRedo = undoStack.canRedo; + const cutsQuery = trpc.course.getOverprintCuts.useQuery(undefined, { + staleTime: Number.POSITIVE_INFINITY, + }); + const setCuts = trpc.course.setOverprintCuts.useMutation({ + onSuccess: async () => { + await cutsQuery.refetch(); + await utils.course.geometry.invalidate(); + await utils.course.list.invalidate(); + }, + }); + const cutsEnabled = cutsQuery.data?.enabled !== false; + const toolbar = useMemo( () => (
@@ -1152,6 +1164,21 @@ export function CourseEditorPage() { > {t("editor.hideOtherControls")} + {selectedControl && ( void utils.clubMap.list.invalidate(), + }); const fileInputRef = useRef(null); const [dragOver, setDragOver] = useState(false); const [editingId, setEditingId] = useState(null); @@ -312,6 +315,52 @@ export function SettingsPage() { {t("northLinesNone")} )} + +
{canManageSource(row.uploadedBy) && ( diff --git a/scripts/__tests__/ocad-fixture.test.mjs b/scripts/__tests__/ocad-fixture.test.mjs index 4275832..ac8e53d 100644 --- a/scripts/__tests__/ocad-fixture.test.mjs +++ b/scripts/__tests__/ocad-fixture.test.mjs @@ -65,11 +65,6 @@ describe("showcase SQL map substitution", () => { const sql = [ "-- header", `INSERT INTO map_files ("event_id", "file_name", "file_data", "uploaded_at") VALUES (9876543, E'Vinter Flaten kartutsitt.ocd', '\\x${"aa".repeat(100)}'::bytea, '2026-01-01T00:00:00.000Z');`, - `INSERT INTO map_tiles ("event_id", "z", "x", "y", "tile_data") VALUES (9876543, 10, 563, 301, '\\x89504e47');`, - `INSERT INTO map_tiles ("event_id", "z", "x", "y", "tile_data") VALUES (9876543, 11, 1127, 603, '\\x89504e47');`, - `INSERT INTO map_tiles ("event_id", "z", "x", "y", "tile_data") VALUES (9876543, 12, 2254, 1206, '\\x89504e47');`, - `INSERT INTO map_tiles ("event_id", "z", "x", "y", "tile_data") VALUES (9876543, 13, 4508, 2412, '\\x89504e47');`, - `INSERT INTO map_tiles ("event_id", "z", "x", "y", "tile_data") VALUES (9876543, 13, 4509, 2412, '\\x89504e47');`, "-- H:\\Delade enheter\\Skogsluffarna\\Arrangemang", ].join("\n"); @@ -78,7 +73,7 @@ describe("showcase SQL map substitution", () => { assert.doesNotMatch(out, /Vinter Flaten/); assert.doesNotMatch(out, /kartutsitt/); assert.match(out, new RegExp(pgByteaLiteral(ocad).replace(/\\/g, "\\\\"))); - assert.equal((out.match(/INSERT INTO map_tiles/g) || []).length, 5); + assert.equal((out.match(/INSERT INTO map_tiles/g) || []).length, 0); }); it("keeps the committed fixture on the generated showcase OCAD", () => { @@ -86,7 +81,7 @@ describe("showcase SQL map substitution", () => { path.join(ROOT, "docs/screenshots/fixtures/showcase.sql"), "utf8", ); - assert.equal(countMapTileInserts(sql), 5); + assert.equal(countMapTileInserts(sql), 0); const map = extractMapFileFromSql(sql); assert.equal(map?.fileName, SHOWCASE_OCAD.fileName); assert.ok(map?.data); diff --git a/scripts/anonymize-vinterserien.ts b/scripts/anonymize-vinterserien.ts index 35eaee0..2cdcc67 100644 --- a/scripts/anonymize-vinterserien.ts +++ b/scripts/anonymize-vinterserien.ts @@ -15,8 +15,6 @@ * packages/api/.env's value, or * postgresql://oxygen:oxygen@localhost:5432/oxygen?schema=oxygen) * SRC_NAME_ID — source event name_id (default: Vinterserien) - * MAX_ZOOM — maximum cached tile zoom to include (default: 13). - * Deeper tiles render on demand from the OCAD blob. * * Transformations: * - events: rename to "Demo Competition" / "demo_competition", @@ -33,7 +31,7 @@ * - map_files: file_name / file_data always replaced with a generated * synthetic OCAD that keeps the source CRS and paper extent. A source * map export is never written into the fixture. - * - map_tiles: filtered to z <= MAX_ZOOM. + * - map_tiles: omitted (content-keyed cache; regenerates on first view). * - All BIGSERIAL ids on map_files / rendered_maps / tracks / routes * are stripped from the INSERTs so Postgres allocates fresh ones * against the freshly-created demo event. @@ -59,7 +57,6 @@ const DATABASE_URL = process.env.DATABASE_URL ?? "postgresql://oxygen:oxygen@localhost:5432/oxygen?schema=oxygen"; const SRC_NAME_ID = process.env.SRC_NAME_ID ?? "Vinterserien"; -const MAX_ZOOM = parseInt(process.env.MAX_ZOOM ?? "13", 10); const OUT_PATH = path.resolve("docs/screenshots/fixtures/showcase.sql"); const CARD_REMAP_OFFSET = 9_000_000; @@ -83,7 +80,6 @@ type Mode = | "remapCard" | "remapCardReadout" | "remapPunch" - | "filterTiles" | "anonymizeEvent" | "stripSerialId" | "syntheticMap"; @@ -113,7 +109,6 @@ const TABLES: { name: string; mode: Mode; whereExtra?: string }[] = [ { name: "teams", mode: "anonymizeTeam" }, // Map / GPS. { name: "map_files", mode: "syntheticMap" }, - { name: "map_tiles", mode: "filterTiles" }, { name: "tracks", mode: "stripSerialId" }, { name: "routes", mode: "stripSerialId" }, ]; @@ -303,13 +298,10 @@ async function main(): Promise { "-- courses / classes, pseudonymous runners, cards with\n", ); write( - "-- remapped CardNo, a synthetic OCAD backing map, cached\n", + "-- remapped CardNo, a synthetic OCAD backing map, and the\n", ); - write( - `-- map tiles (z <= ${MAX_ZOOM}), and the subset of\n`, - ); - write("-- club_directory referenced by demo runners.\n"); - write("-- Excluded: renderer caches, event_log, eventor_* caches.\n"); + write("-- subset of club_directory referenced by demo runners.\n"); + write("-- Excluded: map_tiles cache, renderer caches, event_log, eventor_* caches.\n"); write("--\n"); write( `-- Load with: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f showcase.sql\n`, @@ -381,9 +373,6 @@ async function main(): Promise { // Tables without event_id are joined via a parent table. where = scopeByJoin(t.name, srcEventId); } - if (t.name === "map_tiles") { - where += ` AND z <= ${MAX_ZOOM}`; - } const result = await client.query({ text: `SELECT * FROM ${t.name} ${where}`, @@ -521,7 +510,7 @@ function transform( cols: ColumnInfo[], mode: Mode, ): unknown[] | null { - if (mode === "keep" || mode === "stripSerialId" || mode === "filterTiles") + if (mode === "keep" || mode === "stripSerialId") return row; const r = [...row]; diff --git a/scripts/patch-showcase-map.mjs b/scripts/patch-showcase-map.mjs index 7114a2d..1d2060e 100644 --- a/scripts/patch-showcase-map.mjs +++ b/scripts/patch-showcase-map.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node /** * Replace the map_files blob in docs/screenshots/fixtures/showcase.sql - * with the synthetic showcase OCAD. Cached map_tiles are left untouched. + * with the synthetic showcase OCAD. map_tiles are not stored in the + * fixture (content-keyed cache regenerates on first view). * * Used when regenerating the committed fixture without a live Vinterserien * source event. `scripts/anonymize-vinterserien.ts` performs the same @@ -19,12 +20,12 @@ const FIXTURE = path.join(ROOT, "docs/screenshots/fixtures/showcase.sql"); const before = readFileSync(FIXTURE, "utf8"); const tiles = countMapTileInserts(before); -if (tiles !== 5) { - throw new Error(`Expected 5 map_tiles INSERTs, found ${tiles}`); +if (tiles !== 0) { + throw new Error(`Expected 0 map_tiles INSERTs, found ${tiles}`); } const ocad = buildShowcaseOcad(); const after = replaceShowcaseMapFile(before, ocad); writeFileSync(FIXTURE, after); console.log( - `patched ${FIXTURE}: synthetic OCAD ${ocad.length} bytes, ${tiles} overview tiles kept`, + `patched ${FIXTURE}: synthetic OCAD ${ocad.length} bytes (tiles regenerate on view)`, );