mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(studio): fit an outline map to the country the trip was in
Closes #2021 A country is one path but not one landmass. France's is five rings, three of them Kerguelen, French Guiana and New Caledonia, so fitting a map to its bounding box asked for 221 degrees of longitude and printed a Paris-Lyon-Nice route as a single dot. Britain does it through South Georgia, Norway through Svalbard, Ecuador through the Galapagos — which is why a Netherlands journey looks perfect and a France one does not. Satellite only escaped by accident: imagery leaves the shape list empty, so the fit fell through to the stops. Same control, silently doing nothing. Fit to the rings the journey is actually near, and keep the whole shape as the fallback for a map with no route yet. The margin is the route's own size with a two-degree floor, which is the judgement in this fix: at two degrees a Paris-Nice route still keeps Corsica, which is the picture that trip wants, while Kerguelen stays out. The outlines themselves are untouched — the overseas rings are still drawn, they just run off the frame the way a coastline does.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { bookMapElementSchema } from '@trek/shared'
|
||||
import { TravelElementView } from './TravelElements'
|
||||
import { COUNTRY_SHAPES, countryParts } from './countryShapes'
|
||||
|
||||
/*
|
||||
* The maps are measured, not snapshotted: what went wrong in #2021 is a number
|
||||
* — how far apart the stops end up on the paper — and a snapshot of a country
|
||||
* outline says nothing about whether the route inside it is legible.
|
||||
*/
|
||||
|
||||
const FRAME = { x: 0, y: 0, w: 100, h: 100 }
|
||||
|
||||
const PARIS = { lat: 48.86, lng: 2.35 }
|
||||
const LYON = { lat: 45.76, lng: 4.84 }
|
||||
const NICE = { lat: 43.7, lng: 7.27 }
|
||||
|
||||
function mapEl(over: Record<string, unknown>) {
|
||||
return bookMapElementSchema.parse({ id: 'm1', kind: 'map', frame: FRAME, ...over })
|
||||
}
|
||||
|
||||
/** How wide the stops sit on the page, in millimetres of a 100mm frame. */
|
||||
function pinSpan(over: Record<string, unknown>): number {
|
||||
const { container } = render(
|
||||
<TravelElementView el={mapEl(over)} frameStyle={{ width: '100mm', height: '100mm' }} />,
|
||||
)
|
||||
const xs = [...container.querySelectorAll('circle')].map(c => Number(c.getAttribute('cx')))
|
||||
expect(xs.length).toBeGreaterThan(0)
|
||||
return Math.max(...xs) - Math.min(...xs)
|
||||
}
|
||||
|
||||
describe('MapView fit', () => {
|
||||
it('frames a French route without the overseas territories', () => {
|
||||
expect(pinSpan({ countries: ['FR'], points: [PARIS, LYON, NICE] })).toBeGreaterThan(20)
|
||||
})
|
||||
|
||||
it('frames a British route without South Georgia', () => {
|
||||
const span = pinSpan({
|
||||
countries: ['GB'],
|
||||
points: [{ lat: 51.51, lng: -0.13 }, { lat: 55.95, lng: -3.19 }],
|
||||
})
|
||||
// Smaller than the French case because Britain really is drawn whole here,
|
||||
// which is what the setting asks for. The South Atlantic is not.
|
||||
expect(span).toBeGreaterThan(10)
|
||||
})
|
||||
|
||||
it('frames the route when imagery is cut to the country outline', () => {
|
||||
const span = pinSpan({
|
||||
source: 'tiles', clip: 'country', countries: ['FR'], points: [PARIS, LYON, NICE],
|
||||
})
|
||||
expect(span).toBeGreaterThan(20)
|
||||
})
|
||||
|
||||
it('still draws the whole of a compact country around the route', () => {
|
||||
const span = pinSpan({
|
||||
countries: ['NL'],
|
||||
points: [{ lat: 52.37, lng: 4.9 }, { lat: 51.92, lng: 4.48 }],
|
||||
})
|
||||
// The Netherlands is small enough that "whole country" is still a useful
|
||||
// picture, so this one must NOT grow: the fix is about which rings count,
|
||||
// not about abandoning the fit.
|
||||
expect(span).toBeLessThan(10)
|
||||
})
|
||||
|
||||
it('frames the overseas ring when that is where the trip was', () => {
|
||||
// Cayenne and Kourou. The right answer is French Guiana, not metropolitan
|
||||
// France, so picking the nearest rings has to work in both directions.
|
||||
const span = pinSpan({
|
||||
countries: ['FR'],
|
||||
points: [{ lat: 4.92, lng: -52.33 }, { lat: 5.16, lng: -52.65 }],
|
||||
})
|
||||
expect(span).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
it('falls back to the whole country when there is no route yet', () => {
|
||||
// An empty map element still has to draw something, and the something is
|
||||
// the country — every ring of it, since nothing says which one matters.
|
||||
const { container } = render(
|
||||
<TravelElementView
|
||||
el={mapEl({ countries: ['FR'], points: [], path: [] })}
|
||||
frameStyle={{ width: '100mm', height: '100mm' }}
|
||||
/>,
|
||||
)
|
||||
expect(container.querySelector('path[d]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('leaves Satellite alone', () => {
|
||||
const span = pinSpan({ source: 'tiles', countries: ['FR'], points: [PARIS, LYON, NICE] })
|
||||
expect(span).toBeGreaterThan(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('countryParts', () => {
|
||||
it('splits France into its five landmasses', () => {
|
||||
const lng = countryParts(COUNTRY_SHAPES.FR)
|
||||
.map(p => [Math.round(p[0] * 100) / 100, Math.round(p[2] * 100) / 100])
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
expect(lng).toEqual([
|
||||
[-54.55, -51.67], // French Guiana
|
||||
[-4.69, 8.16], // metropolitan France
|
||||
[8.6, 9.49], // Corsica
|
||||
[68.87, 70.42], // Kerguelen
|
||||
[163.93, 167.03], // New Caledonia
|
||||
])
|
||||
// The aggregate the outlines are still drawn from is untouched.
|
||||
expect(COUNTRY_SHAPES.FR.b).toEqual([-54.55, -59.62, 167.03, 57.49])
|
||||
})
|
||||
|
||||
it('gives a country with one landmass a single box matching its bounds', () => {
|
||||
const parts = countryParts(COUNTRY_SHAPES.CH)
|
||||
expect(parts).toHaveLength(1)
|
||||
expect(parts[0][0]).toBeCloseTo(COUNTRY_SHAPES.CH.b[0], 1)
|
||||
expect(parts[0][2]).toBeCloseTo(COUNTRY_SHAPES.CH.b[2], 1)
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { brightness } from './folioColour'
|
||||
import { COUNTRY_SHAPES, countryWorldPath, projectMercator, unprojectMercator } from './countryShapes'
|
||||
import { COUNTRY_SHAPES, countryParts, countryWorldPath, projectMercator, unprojectMercator } from './countryShapes'
|
||||
import { projectOntoTiles, tileView, usableStaticUrl } from './mapTiles'
|
||||
import { FLAG_H, FLAG_W, flagBands, flagDisc, flagSpec } from './flags'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
@@ -270,12 +270,6 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
*/
|
||||
const cutToLand = el.clip === 'country' && shapes.length > 0
|
||||
const fitToLand = el.fitToCountries || cutToLand || (points.length === 0 && trail.length === 0)
|
||||
if (fitToLand) {
|
||||
for (const s of shapes) {
|
||||
minX = Math.min(minX, s.b[0]); minY = Math.min(minY, s.b[1])
|
||||
maxX = Math.max(maxX, s.b[2]); maxY = Math.max(maxY, s.b[3])
|
||||
}
|
||||
}
|
||||
for (const p of points) {
|
||||
minX = Math.min(minX, p.x); minY = Math.min(minY, p.y)
|
||||
maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y)
|
||||
@@ -300,6 +294,45 @@ function MapView({ el, frameStyle, big = false }: {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The country fit comes last, because it needs to know where the journey is.
|
||||
*
|
||||
* A country is one path but not one landmass. France's is five rings, three
|
||||
* of them Kerguelen, French Guiana and New Caledonia, so unioning its whole
|
||||
* bounding box fitted a Paris-Lyon-Nice route to 221 degrees of longitude and
|
||||
* printed the three stops as a single dot. Britain does the same through
|
||||
* South Georgia, Norway through Svalbard, Ecuador through the Galapagos.
|
||||
*
|
||||
* So only the rings the journey is near are fitted to. The margin is the
|
||||
* route's own size, floored at two degrees, and that floor is the whole of
|
||||
* the judgement: at two degrees a Paris-Nice route still pulls in Corsica,
|
||||
* which is the picture that trip wants, while Kerguelen at 68 degrees east
|
||||
* stays out. Raise it far and the territories come back; drop it to zero and
|
||||
* a coastal route sitting just off a simplified coastline stops matching the
|
||||
* country it is in.
|
||||
*
|
||||
* The whole shape is still the fallback — with no route there is nothing to
|
||||
* measure against, and a country none of whose rings match is better drawn
|
||||
* whole than not at all. The outlines themselves are unchanged either way:
|
||||
* the overseas rings are still painted, they just run off the frame the way
|
||||
* a coastline does.
|
||||
*/
|
||||
if (fitToLand) {
|
||||
const routed = Number.isFinite(minX)
|
||||
const margin = Math.max(maxX - minX, maxY - minY, 2)
|
||||
const nearX0 = minX - margin, nearY0 = minY - margin
|
||||
const nearX1 = maxX + margin, nearY1 = maxY + margin
|
||||
for (const s of shapes) {
|
||||
const near = routed
|
||||
? countryParts(s).filter(p => p[0] <= nearX1 && p[2] >= nearX0 && p[1] <= nearY1 && p[3] >= nearY0)
|
||||
: []
|
||||
for (const b of near.length > 0 ? near : [s.b]) {
|
||||
minX = Math.min(minX, b[0]); minY = Math.min(minY, b[1])
|
||||
maxX = Math.max(maxX, b[2]); maxY = Math.max(maxY, b[3])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* ── The bow ───────────────────────────────────────────────────────────
|
||||
*
|
||||
|
||||
@@ -59,6 +59,44 @@ export function countryWorldPath(shape: CountryShape): string {
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* One box per ring of the outline, in the same world coordinates.
|
||||
*
|
||||
* A country is a single path but not a single landmass. France's is five
|
||||
* subpaths, three of which are Kerguelen, French Guiana and New Caledonia, so
|
||||
* its `b` covers 221 degrees of longitude and describes nowhere anybody stood.
|
||||
* Anything fitting a view to a country needs the pieces, not the aggregate.
|
||||
*
|
||||
* Cached against the shape object: the boxes are a property of the generated
|
||||
* data, and the data does not change while the page is open.
|
||||
*/
|
||||
const PARTS = new WeakMap<CountryShape, [number, number, number, number][]>()
|
||||
|
||||
export function countryParts(shape: CountryShape): [number, number, number, number][] {
|
||||
const cached = PARTS.get(shape)
|
||||
if (cached) return cached
|
||||
// The same placement `countryWorldPath` does, applied to the numbers rather
|
||||
// than to the string.
|
||||
const span = Math.max(shape.b[2] - shape.b[0], shape.b[3] - shape.b[1]) / 100
|
||||
const parts: [number, number, number, number][] = []
|
||||
for (const sub of shape.d.split('M')) {
|
||||
const nums = sub.match(/-?\d*\.?\d+/g)
|
||||
if (!nums || nums.length < 2) continue
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
||||
for (let i = 0; i + 1 < nums.length; i += 2) {
|
||||
const x = Number(nums[i]) * span + shape.b[0]
|
||||
const y = Number(nums[i + 1]) * span + shape.b[1]
|
||||
if (x < minX) minX = x
|
||||
if (x > maxX) maxX = x
|
||||
if (y < minY) minY = y
|
||||
if (y > maxY) maxY = y
|
||||
}
|
||||
parts.push([minX, minY, maxX, maxY])
|
||||
}
|
||||
PARTS.set(shape, parts)
|
||||
return parts
|
||||
}
|
||||
|
||||
export const COUNTRY_SHAPES: Record<string, CountryShape> = {
|
||||
AD: { d: 'M84.2 57.2L84.2 50L81.6 50L84.2 46.5L84.2 42.9L86.8 42.9L86.8 35.8L92.1 35.8L92.1 32.2L92.1 35.8L94.7 32.2L94.7 35.8L94.7 32.2L100 32.2L97.4 28.6L89.5 28.6L86.8 25L84.2 25L84.2 17.9L86.8 17.9L86.8 14.3L84.2 14.3L84.2 17.9L84.2 14.3L81.6 14.3L78.9 17.9L78.9 14.3L65.8 14.3L63.2 10.7L44.7 10.7L44.7 3.6L36.8 3.6L36.8 0L34.2 3.6L18.4 3.6L18.4 7.2L15.8 7.2L15.8 14.3L18.4 14.3L18.4 17.9L13.2 17.9L13.2 21.5L7.9 21.5L7.9 25L5.3 25L5.3 28.6L7.9 32.2L7.9 35.8L2.6 35.8L2.6 42.9L0 42.9L10.5 42.9L10.5 46.5L13.2 46.5L13.2 50L15.8 53.6L13.2 53.6L13.2 57.2L7.9 57.2L5.3 60.8L2.6 60.8L2.6 64.3L7.9 64.3L7.9 71.5L10.5 71.5L7.9 71.5L7.9 75L10.5 75L10.5 78.6L7.9 78.6L18.4 78.6L18.4 82.2L36.8 82.2L36.8 78.6L39.5 75L39.5 71.5L42.1 71.5L42.1 75L44.7 75L44.7 71.5L47.4 71.5L50 67.9L52.6 71.5L52.6 67.9L55.3 67.9L57.9 71.5L57.9 67.9L65.8 67.9L65.8 60.8L68.4 57.2L71.1 57.2L71.1 60.8L73.7 57.2L73.7 60.8L78.9 60.8L84.2 57.2Z', w: 100, h: 82.2, b: [1.41, -47.25, 1.79, -46.94] },
|
||||
AE: { d: 'M99.8 25.1L99.6 11.1L95.6 9.9L95 5.5L96 2.1L95 0L93.8 0.5L92.3 4.2L93.1 3.2L93.3 3.9L91.1 5.8L91.3 6.9L91.1 6L89.4 8.1L87.5 7.9L87.7 8.8L86.9 9.5L86.7 8.6L86.7 9.9L84.4 12.5L83 12.5L83.4 10.6L81.5 13.6L82.1 13.9L80.5 14.8L81.3 14.5L81.1 15.5L80.5 14.8L79.4 17.1L79 16.2L78.8 17.8L77.3 16.4L78.2 20.3L78.2 18.9L76.7 18.2L77.1 18.9L76.5 18.5L76.9 19.1L74.4 22.8L72.1 24.2L72.6 25.3L71.3 23.7L71.7 25.1L71.3 24L70.7 25.1L70.9 23.7L70.3 25.1L71.1 25.3L69.6 26.3L69.2 25.6L69.6 26.5L68.2 26.9L69.2 28.1L68 26.9L66.3 27.9L66.9 29.2L66.1 29.9L66.3 27.6L65.3 28.8L66.1 29.2L64.9 29L64.7 30.6L63.6 31.1L64.9 32.4L63 35.4L63.8 36.3L60.7 37.7L61.3 38.8L60.1 39.3L59.9 40.2L61.1 40.4L55.5 42.3L53.2 40L52.4 41.6L53.4 42.5L50.9 43.2L52 43.9L53.2 42.5L54.1 42.9L52.6 44.5L52 43.9L52 44.8L48.6 44.8L48.9 45.9L42 46.6L39.1 44.5L36.2 44.8L36.2 45.5L35.3 44.3L35.6 45.2L34.1 44.5L34.5 45.9L32.8 46.1L32 43.9L31.2 44.8L30.4 44.5L31 43.9L24.5 44.5L23.7 43.4L22.9 44.5L21.2 42.5L20.8 44.5L18.7 45.5L17.9 44.8L18.1 46.1L14.8 48L10.8 48.9L7.9 47.5L7.5 48.6L4.4 46.8L4.4 41.4L3.5 40.9L2.5 42.7L1.7 40L2.1 42.3L1.2 42.9L1.5 40.2L0 38.6L0.4 44.5L21 71.5L74.2 78.5L76.1 75.1L76.1 67.7L83.2 56.4L82.5 51.1L81.3 48.9L86.5 46.1L88.6 47.3L92.5 45.9L91.3 42.5L86.9 42.3L88.6 40.2L88.6 37.9L87.3 35.9L88.4 33.4L87.7 32.9L88.8 32.2L88.1 26.7L91.3 24.4L93.1 25.3L93.3 27.6L91.7 27.6L94.4 30.6L96.3 29.7L96.5 28.1L98.5 27.4L99.4 26.3L98.8 25.3L99.8 25.1Z', w: 100, h: 78.5, b: [51.57, -27.02, 56.38, -23.24] },
|
||||
|
||||
Reference in New Issue
Block a user