mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Country silhouettes for TREK Studio, built from the boundaries the server
|
||||
* already bundles for Atlas.
|
||||
*
|
||||
* node scripts/build-country-shapes.mjs
|
||||
*
|
||||
* Writes `src/components/Studio/countryShapes.ts`. Run it when
|
||||
* `server/assets/atlas/admin0.geojson.gz` changes; the output is committed, so
|
||||
* a normal build never runs this.
|
||||
*
|
||||
* ── Why a second, tiny copy of the boundaries ─────────────────────────────
|
||||
*
|
||||
* Atlas's admin-0 file is 4.3MB gzipped and ~30MB parsed, sized for a slippy
|
||||
* map you can zoom into. A book page draws a country at 20mm across, where the
|
||||
* ten thousand points describing a fjord land inside one printer dot. Shipping
|
||||
* the map data to draw a stamp would cost more than the entire Studio bundle.
|
||||
*
|
||||
* So: project, drop the islands nobody can see at that size, simplify hard, and
|
||||
* normalise each country into its own 0..100 box. What comes out is a few
|
||||
* hundred bytes per country and prints identically, because at 20mm it *is*
|
||||
* identical.
|
||||
*
|
||||
* ── The three things that are easy to get wrong ───────────────────────────
|
||||
*
|
||||
* 1. **Project before simplifying.** Simplifying in degrees and projecting
|
||||
* afterwards removes the wrong points: a degree of longitude is 111km at the
|
||||
* equator and 30km at Reykjavík, so a fixed tolerance eats Iceland's coast
|
||||
* while leaving Kenya's untouched.
|
||||
* 2. **Countries that cross the antimeridian.** Russia and Fiji have parts at
|
||||
* +179 and -179. Taken literally their bounding box is the whole world and
|
||||
* the silhouette is a horizontal smear. Rings that span more than half the
|
||||
* globe get their negative longitudes lifted by 360 so the country is
|
||||
* contiguous again.
|
||||
* 3. **Islands.** Keeping every ring turns Norway into a dust cloud and Greece
|
||||
* into static. Rings below a fraction of the largest one are dropped — the
|
||||
* mainland is what makes a country recognisable at a glance.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import url from 'node:url'
|
||||
import zlib from 'node:zlib'
|
||||
|
||||
const HERE = path.dirname(url.fileURLToPath(import.meta.url))
|
||||
const SRC = path.join(HERE, '..', '..', 'server', 'assets', 'atlas', 'admin0.geojson.gz')
|
||||
const OUT = path.join(HERE, '..', 'src', 'components', 'Studio', 'countryShapes.ts')
|
||||
|
||||
/** Rings smaller than this share of the biggest one are not drawn. */
|
||||
const ISLAND_CUTOFF = 0.012
|
||||
/** Never draw more than this many rings for one country. */
|
||||
const MAX_RINGS = 14
|
||||
/** Point budget per country, across every ring it keeps. */
|
||||
const MAX_POINTS = 190
|
||||
|
||||
/** Web Mercator, unscaled. y is flipped so it grows downwards like SVG's. */
|
||||
function project([lng, lat]) {
|
||||
const clamped = Math.max(-85.05, Math.min(85.05, lat))
|
||||
const rad = (clamped * Math.PI) / 180
|
||||
return [lng, -(Math.log(Math.tan(Math.PI / 4 + rad / 2)) * 180) / Math.PI]
|
||||
}
|
||||
|
||||
function ringArea(ring) {
|
||||
let a = 0
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
a += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1]
|
||||
}
|
||||
return Math.abs(a / 2)
|
||||
}
|
||||
|
||||
/** Perpendicular distance from p to the segment ab, squared. */
|
||||
function segDistSq(p, a, b) {
|
||||
let x = a[0], y = a[1]
|
||||
let dx = b[0] - x, dy = b[1] - y
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
const t = ((p[0] - x) * dx + (p[1] - y) * dy) / (dx * dx + dy * dy)
|
||||
if (t > 1) { x = b[0]; y = b[1] } else if (t > 0) { x += dx * t; y += dy * t }
|
||||
}
|
||||
dx = p[0] - x
|
||||
dy = p[1] - y
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
/** Douglas-Peucker, iterative so a long coastline cannot blow the stack. */
|
||||
function simplify(points, tolerance) {
|
||||
if (points.length < 3) return points
|
||||
const tolSq = tolerance * tolerance
|
||||
const keep = new Uint8Array(points.length)
|
||||
keep[0] = 1
|
||||
keep[points.length - 1] = 1
|
||||
const stack = [[0, points.length - 1]]
|
||||
|
||||
while (stack.length) {
|
||||
const [first, last] = stack.pop()
|
||||
let maxSq = 0
|
||||
let index = 0
|
||||
for (let i = first + 1; i < last; i++) {
|
||||
const d = segDistSq(points[i], points[first], points[last])
|
||||
if (d > maxSq) { maxSq = d; index = i }
|
||||
}
|
||||
if (maxSq > tolSq) {
|
||||
keep[index] = 1
|
||||
stack.push([first, index], [index, last])
|
||||
}
|
||||
}
|
||||
return points.filter((_, i) => keep[i])
|
||||
}
|
||||
|
||||
/** Every outer ring of a Polygon or MultiPolygon, holes discarded. */
|
||||
function outerRings(geometry) {
|
||||
if (!geometry) return []
|
||||
if (geometry.type === 'Polygon') return geometry.coordinates.slice(0, 1)
|
||||
if (geometry.type === 'MultiPolygon') return geometry.coordinates.map(poly => poly[0])
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift a ring that straddles the antimeridian into a continuous longitude
|
||||
* range. A ring whose points span more than half the globe is, in practice,
|
||||
* always a wrap rather than a genuinely hemisphere-wide landmass.
|
||||
*/
|
||||
function unwrap(ring) {
|
||||
let min = Infinity
|
||||
let max = -Infinity
|
||||
for (const [lng] of ring) { if (lng < min) min = lng; if (lng > max) max = lng }
|
||||
if (max - min <= 180) return ring
|
||||
return ring.map(([lng, lat]) => [lng < 0 ? lng + 360 : lng, lat])
|
||||
}
|
||||
|
||||
function build(feature) {
|
||||
const rings = outerRings(feature.geometry)
|
||||
.map(unwrap)
|
||||
.map(ring => ring.map(project))
|
||||
.map(ring => ({ ring, area: ringArea(ring) }))
|
||||
.sort((a, b) => b.area - a.area)
|
||||
|
||||
if (!rings.length) return null
|
||||
|
||||
const biggest = rings[0].area
|
||||
const kept = rings
|
||||
.filter(r => r.area >= biggest * ISLAND_CUTOFF)
|
||||
.slice(0, MAX_RINGS)
|
||||
.map(r => r.ring)
|
||||
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
||||
for (const ring of kept) {
|
||||
for (const [x, y] of ring) {
|
||||
if (x < minX) minX = x
|
||||
if (x > maxX) maxX = x
|
||||
if (y < minY) minY = y
|
||||
if (y > maxY) maxY = y
|
||||
}
|
||||
}
|
||||
const spanX = maxX - minX
|
||||
const spanY = maxY - minY
|
||||
if (!(spanX > 0) || !(spanY > 0)) return null
|
||||
|
||||
// The longer side becomes 100 and the shorter one keeps its proportion, so a
|
||||
// country never has to be squashed to fit the box it is drawn in.
|
||||
const scale = 100 / Math.max(spanX, spanY)
|
||||
const w = spanX * scale
|
||||
const h = spanY * scale
|
||||
const normalised = kept.map(ring =>
|
||||
ring.map(([x, y]) => [(x - minX) * scale, (y - minY) * scale]))
|
||||
|
||||
// Tolerance climbs until the whole country fits its point budget. Starting
|
||||
// from the diagonal keeps the loop short for a country of any size.
|
||||
let tolerance = 0.14
|
||||
let simplified = normalised.map(r => simplify(r, tolerance))
|
||||
let guard = 0
|
||||
while (simplified.reduce((n, r) => n + r.length, 0) > MAX_POINTS && guard++ < 40) {
|
||||
tolerance *= 1.35
|
||||
simplified = normalised.map(r => simplify(r, tolerance))
|
||||
}
|
||||
|
||||
const d = simplified
|
||||
.filter(ring => ring.length >= 3)
|
||||
.map(ring => `M${ring.map(([x, y]) => `${round(x)} ${round(y)}`).join('L')}Z`)
|
||||
.join('')
|
||||
|
||||
// The projected bounds, kept so the same path can be replayed in world
|
||||
// coordinates. A country drawn on its own wants its own box; the same country
|
||||
// on a route map has to sit where it actually is, next to its neighbours. One
|
||||
// normalised path plus these four numbers serves both, where storing two sets
|
||||
// of coordinates would be twice the bytes and one more thing to disagree.
|
||||
return d ? { d, w: round(w), h: round(h), b: [minX, minY, maxX, maxY].map(v => Math.round(v * 100) / 100) } : null
|
||||
}
|
||||
|
||||
const round = n => Math.round(n * 10) / 10
|
||||
|
||||
const raw = zlib.gunzipSync(fs.readFileSync(SRC))
|
||||
const collection = JSON.parse(raw.toString('utf8'))
|
||||
|
||||
const shapes = new Map()
|
||||
for (const feature of collection.features) {
|
||||
const code = String(feature.properties?.ISO_A2 || '').toUpperCase()
|
||||
if (code.length !== 2 || code === '-9') continue
|
||||
const built = build(feature)
|
||||
if (!built) continue
|
||||
// A code appearing twice means two features for one country; keep the one
|
||||
// with more outline, which is the mainland rather than a dependency.
|
||||
const prev = shapes.get(code)
|
||||
if (!prev || built.d.length > prev.d.length) shapes.set(code, built)
|
||||
}
|
||||
|
||||
const codes = [...shapes.keys()].sort()
|
||||
const body = codes
|
||||
.map(code => {
|
||||
const s = shapes.get(code)
|
||||
return ` ${code}: { d: '${s.d}', w: ${s.w}, h: ${s.h}, b: [${s.b.join(', ')}] },`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const file = `/**
|
||||
* GENERATED — do not edit. Run \`node scripts/build-country-shapes.mjs\`.
|
||||
*
|
||||
* Country silhouettes, simplified from the admin-0 boundaries the server
|
||||
* bundles for Atlas (geoBoundaries, CC BY 4.0). Each path is Web Mercator,
|
||||
* normalised so the longer side is 100 units; \`w\` and \`h\` give the shape's
|
||||
* own proportions inside that box so it can be drawn without being squashed.
|
||||
*
|
||||
* ${codes.length} countries, built for print at postage-stamp size — islands
|
||||
* below ${ISLAND_CUTOFF * 100}% of the mainland are dropped and each outline is
|
||||
* simplified to at most ${MAX_POINTS} points.
|
||||
*/
|
||||
|
||||
export interface CountryShape {
|
||||
/** Path on a 0..100 box, outer rings only. */
|
||||
d: string
|
||||
/** The shape's own width and height inside that box. */
|
||||
w: number
|
||||
h: number
|
||||
/** Projected bounds \`[minX, minY, maxX, maxY]\`, for drawing it in place. */
|
||||
b: [number, number, number, number]
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Mercator, matching the projection the paths were built with. Longitude
|
||||
* passes through as degrees; latitude is flipped so y grows downwards like
|
||||
* SVG's does.
|
||||
*/
|
||||
export function projectMercator(lng: number, lat: number): { x: number; y: number } {
|
||||
const clamped = Math.max(-85.05, Math.min(85.05, lat))
|
||||
const rad = (clamped * Math.PI) / 180
|
||||
return { x: lng, y: -(Math.log(Math.tan(Math.PI / 4 + rad / 2)) * 180) / Math.PI }
|
||||
}
|
||||
|
||||
/**
|
||||
* The same outline, placed in projected world coordinates.
|
||||
*
|
||||
* The stored path is normalised into its own box; multiplying by the box's span
|
||||
* and shifting by its origin puts it back where the country actually is, which
|
||||
* is what a map with more than one country on it needs.
|
||||
*/
|
||||
export function countryWorldPath(shape: CountryShape): string {
|
||||
const span = Math.max(shape.b[2] - shape.b[0], shape.b[3] - shape.b[1]) / 100
|
||||
let axis = 0
|
||||
return shape.d.replace(/-?\\d*\\.?\\d+/g, m => {
|
||||
const isX = axis++ % 2 === 0
|
||||
const v = Number(m) * span + (isX ? shape.b[0] : shape.b[1])
|
||||
return String(Math.round(v * 1000) / 1000)
|
||||
})
|
||||
}
|
||||
|
||||
export const COUNTRY_SHAPES: Record<string, CountryShape> = {
|
||||
${body}
|
||||
}
|
||||
|
||||
/** The silhouette for an ISO-3166-1 alpha-2 code, or null if we have none. */
|
||||
export function countryShape(code: string | null | undefined): CountryShape | null {
|
||||
if (!code) return null
|
||||
return COUNTRY_SHAPES[code.toUpperCase()] ?? null
|
||||
}
|
||||
`
|
||||
|
||||
fs.writeFileSync(OUT, file, 'utf8')
|
||||
const kb = Math.round(Buffer.byteLength(file) / 102.4) / 10
|
||||
console.log(`[country-shapes] ${codes.length} countries, ${kb} kB → ${path.relative(process.cwd(), OUT)}`)
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Fails when a built chunk contains both WebGL map engines.
|
||||
*
|
||||
* mapbox-gl and maplibre-gl used to be static imports in the same three
|
||||
* components, so rollup emitted a single 2.8 MB chunk carrying both. Every map
|
||||
* user downloaded 1.8 MB of mapbox plus 1.1 MB of maplibre and ran one of them.
|
||||
* A stray static import anywhere in the map tree brings that straight back, and
|
||||
* nothing else would notice — the build stays green and the app still works.
|
||||
*
|
||||
* The markers are chosen to appear only inside the SDKs themselves, verified in
|
||||
* node_modules. Do NOT test for the bare string "maplibre": the provider value
|
||||
* 'maplibre-gl' and the setting key maplibre_style also appear in app code, so it
|
||||
* matches the shared core chunk too.
|
||||
*/
|
||||
const DIR = 'dist/assets'
|
||||
const MAPBOX = 'events.mapbox.com' // 1 hit in mapbox-gl, 0 in maplibre-gl
|
||||
const MAPLIBRE = 'maplibregl-canvas' // 2 hits in maplibre-gl, 0 in mapbox-gl
|
||||
|
||||
let mixed = 0
|
||||
let found = 0
|
||||
|
||||
for (const file of readdirSync(DIR).filter(f => f.endsWith('.js'))) {
|
||||
const path = join(DIR, file)
|
||||
const src = readFileSync(path, 'utf8')
|
||||
const hasMapbox = src.includes(MAPBOX)
|
||||
const hasMaplibre = src.includes(MAPLIBRE)
|
||||
if (!hasMapbox && !hasMaplibre) continue
|
||||
|
||||
found++
|
||||
const size = statSync(path).size
|
||||
if (hasMapbox && hasMaplibre) {
|
||||
console.error(`FAIL both GL engines in one chunk: ${file} (${size} B)`)
|
||||
mixed++
|
||||
} else {
|
||||
console.log(`${hasMapbox ? 'mapbox ' : 'maplibre'} ${file} ${size} B`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
console.error('FAIL no chunk contains either GL engine — did the build run?')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.exit(mixed ? 1 : 0)
|
||||
@@ -2,9 +2,12 @@
|
||||
// src/pages/PATTERN.md). A *Page.tsx default-export component should wire a
|
||||
// co-located use<Page>() hook into JSX — it must not own state/effects itself.
|
||||
//
|
||||
// We scan only the default-export component body (from `export default function`
|
||||
// up to the next top-level `function` declaration or EOF), so presentational
|
||||
// sub-components and helper hooks living in the same file are not flagged.
|
||||
// We scan the default-export component body (from `export default function` up
|
||||
// to the next top-level `function` declaration or EOF), and the same span of a
|
||||
// top-level `*PageDesktop`/`*PageMobile` — since ViewportRoute started picking
|
||||
// the branch, the default export is a two-line shim and the real container is
|
||||
// one of those two. Everything else at top level stays exempt: presentational
|
||||
// sub-components and helper hooks live in these files on purpose.
|
||||
// Context hooks like useTranslation/useParams are fine; the smell is stateful
|
||||
// logic — useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef.
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
@@ -13,23 +16,38 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
const pagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'pages')
|
||||
const BANNED = ['useState', 'useReducer', 'useEffect', 'useLayoutEffect', 'useMemo', 'useCallback', 'useRef']
|
||||
const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*\\(`)
|
||||
// `[<(]` rather than `\(`: a generic call — `useRef<() => void>(null)` — is the
|
||||
// same thing, and it slipped through for as long as the pattern only knew `(`.
|
||||
const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*[<(]`)
|
||||
|
||||
// Declarations the widened scan caught that are not this change's to move.
|
||||
// Listed by the name they declare rather than by line, so an entry dies with
|
||||
// its declaration instead of drifting onto whatever follows it. The rest of
|
||||
// each listed file is still checked.
|
||||
const KNOWN_ESCAPES = {
|
||||
'TripPlannerPage.tsx': ['glMap', 'bookingExpense'],
|
||||
}
|
||||
|
||||
const violations = []
|
||||
for (const file of readdirSync(pagesDir)) {
|
||||
if (!file.endsWith('Page.tsx') || file.endsWith('.test.tsx')) continue
|
||||
const src = readFileSync(join(pagesDir, file), 'utf8')
|
||||
const lines = src.split('\n')
|
||||
const start = lines.findIndex(l => /export default function/.test(l))
|
||||
if (start === -1) continue
|
||||
// The page body ends at the next top-level declaration (a `function` at
|
||||
// column 0) — everything after that is a sub-component or helper.
|
||||
let end = lines.length
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break }
|
||||
}
|
||||
for (let i = start; i < end; i++) {
|
||||
if (bannedRe.test(lines[i])) {
|
||||
const allowed = KNOWN_ESCAPES[file] ?? []
|
||||
const starts = []
|
||||
lines.forEach((line, i) => {
|
||||
if (/export default function/.test(line) || /^function \w+Page(Desktop|Mobile)\b/.test(line)) starts.push(i)
|
||||
})
|
||||
for (const start of starts) {
|
||||
// The page body ends at the next top-level declaration (a `function` at
|
||||
// column 0) — everything after that is a sub-component or helper.
|
||||
let end = lines.length
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break }
|
||||
}
|
||||
for (let i = start; i < end; i++) {
|
||||
if (!bannedRe.test(lines[i])) continue
|
||||
if (allowed.some(name => new RegExp(`\\b${name}\\b`).test(lines[i]))) continue
|
||||
violations.push(`${file}:${i + 1} ${lines[i].trim()}`)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user