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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion web/src/components/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import ContextMenu from './contextmenu.ts'
import { roundPosition } from '../util.ts'
import { setupPhones } from '../phones.ts'
import { setupTracking } from '../tracking.ts'
import { setupGrid } from '../grid.ts'
import { loadIcons } from '../icons.ts'
import { LngLat, LngLatLike } from 'maplibre-gl'

Expand Down Expand Up @@ -97,7 +98,7 @@ export class EMFMap extends LitElement {
]

layer_config: (Layer | LayerGroup)[] = [
new Layer('g', 'Grid', 'grid_'),
new LayerGroup('Grid', [new Layer('g', 'Lines', 'grid_'), new Layer('R', 'References', 'gridref_')]),
new LayerGroup('Background', [
new Layer('b', 'Map', 'background_', 'background', true),
new Layer('s', 'Slope', 'slope', 'background'),
Expand Down Expand Up @@ -227,6 +228,7 @@ export class EMFMap extends LitElement {
loadIcons(map)
setupPhones(map)
setupTracking(map)
setupGrid(map)

map.touchZoomRotate.disableRotation()

Expand Down
144 changes: 144 additions & 0 deletions web/src/grid.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { css, LitElement, html } from 'lit'
import { customElement, property } from 'lit/decorators.js'
import { VectorTile } from '@mapbox/vector-tile'
import Pbf from 'pbf'
import { center } from './style/map_style.ts'

@customElement('emf-map-grid-position')
export class GridPositionControl extends LitElement {
Expand Down Expand Up @@ -66,3 +69,144 @@ export class GridPosition implements maplibregl.IControl {
this._map = undefined
}
}

type XY = [number, number]

/* Zoom at which the whole grid fits inside a single site_plan tile, so every
cell is present and unclipped. Matches the search index (searchindex.ts). */
const GRID_TILE_ZOOM = 11
const TILE_FETCH_TIMEOUT_MS = 5000

function tileForPoint(lng: number, lat: number, z: number): { x: number; y: number } {
const latR = (lat * Math.PI) / 180
const x = Math.floor(((lng + 180) / 360) * 2 ** z)
const y = Math.floor(((1 - Math.log(Math.tan(latR) + 1 / Math.cos(latR)) / Math.PI) / 2) * 2 ** z)
return { x, y }
}

/* Centre of a (rotated-rectangle) cell. Its axis-aligned bounding box is centred
on the cell centre, so this is robust to vertex count and winding. */
function cellCentre(geometry: GeoJSON.Geometry): XY {
const rings =
geometry.type === 'Polygon'
? geometry.coordinates
: geometry.type === 'MultiPolygon'
? geometry.coordinates.flat()
: []
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const ring of rings) {
for (const [lng, lat] of ring) {
if (lng < minX) minX = lng
if (lng > maxX) maxX = lng
if (lat < minY) minY = lat
if (lat > maxY) maxY = lat
}
}
return [(minX + maxX) / 2, (minY + maxY) / 2]
}

function labelPoint(coordinates: XY, axis: 'col' | 'row' | 'cell', label: string): GeoJSON.Feature {
return { type: 'Feature', geometry: { type: 'Point', coordinates }, properties: { axis, label } }
}

/* Generate the grid labels as real geographic points so they stay glued to the
grid during zoom (a screen-space text offset drifts instead). The whole grid
fits in one z11 site_plan tile, so a single fetch gives every cell unclipped:
one point per cell (the "F7" references), plus column letters just above the
top edge and row numbers just left of the grid, stepped out along the grid's
rotation. Mirrors the tile fetch/decode used by the search index. */
export async function setupGrid(map: maplibregl.Map) {
// getStyle() is only valid once the style has loaded. `idle` fires repeatedly,
// so this self-heals if isStyleLoaded() reads false transiently after load.
if (!map.isStyleLoaded()) {
map.once('idle', () => setupGrid(map))
return
}
try {
const source = map.getStyle().sources['site_plan']
if (!source || source.type !== 'vector' || !source.tiles?.length) {
throw new Error('site_plan source has no tile URL')
}
const [lng, lat] = center
const { x, y } = tileForPoint(lng, lat, GRID_TILE_ZOOM)
const url = source.tiles[0]
.replace('{z}', String(GRID_TILE_ZOOM))
.replace('{x}', String(x))
.replace('{y}', String(y))

const resp = await fetch(url, { signal: AbortSignal.timeout(TILE_FETCH_TIMEOUT_MS) })
if (!resp.ok) throw new Error(`grid tile fetch failed: ${resp.status}`)
const layer = new VectorTile(new Pbf(new Uint8Array(await resp.arrayBuffer()))).layers['grid']
if (!layer) throw new Error('grid layer missing from site plan tile')

const centres = new Map<string, XY>()
for (let i = 0; i < layer.length; i++) {
const feature = layer.feature(i)
const { column, row } = feature.properties
if (column == null || row == null) continue
const key = `${column}|${row}`
if (!centres.has(key)) {
centres.set(key, cellCentre(feature.toGeoJSON(x, y, GRID_TILE_ZOOM).geometry))
}
}

const cols = [...new Set([...centres.keys()].map((k) => k.split('|')[0]))].sort()
const rows = [...new Set([...centres.keys()].map((k) => Number(k.split('|')[1])))].sort((a, b) => a - b)
if (cols.length < 2 || rows.length < 2) throw new Error('grid too small to label')

// Average step vector between adjacent columns / rows across the lattice.
const step = (along: 'col' | 'row'): XY => {
let sx = 0
let sy = 0
let n = 0
for (const c of cols) {
for (const r of rows) {
const from = centres.get(`${c}|${r}`)
const to =
along === 'col'
? centres.get(`${cols[cols.indexOf(c) + 1]}|${r}`)
: centres.get(`${c}|${rows[rows.indexOf(r) + 1]}`)
if (from && to) {
sx += to[0] - from[0]
sy += to[1] - from[1]
n++
}
}
}
return n ? [sx / n, sy / n] : [0, 0]
}
const colStep = step('col')
const rowStep = step('row')
const minCol = cols[0]
const minRow = rows[0]

const features: GeoJSON.Feature[] = []
// In-cell references.
for (const [key, centre] of centres) {
const [c, r] = key.split('|')
features.push(labelPoint(centre, 'cell', `${c}${r}`))
}
// Column letters, one step above the top row.
for (const c of cols) {
const top = centres.get(`${c}|${minRow}`)
if (top) features.push(labelPoint([top[0] - rowStep[0], top[1] - rowStep[1]], 'col', c))
}
// Row numbers, one step left of the left column.
for (const r of rows) {
const left = centres.get(`${minCol}|${r}`)
if (left) {
features.push(labelPoint([left[0] - colStep[0], left[1] - colStep[1]], 'row', String(r)))
}
}

;(map.getSource('grid_labels') as maplibregl.GeoJSONSource).setData({
type: 'FeatureCollection',
features,
})
} catch (e) {
console.warn('Grid: labels unavailable', e)
}
}
45 changes: 45 additions & 0 deletions web/src/style/emf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,51 @@ export const layers: LayerSpecificationWithZIndex[] = [
'line-color': '#666',
},
},
{
/* Column letters (A–L) just above the top edge of the grid, and row numbers
(1–18) just left of it (everything in `grid_labels` that isn't an in-cell
reference). The points are generated from the grid cells at runtime
(setupGrid) so the labels are geo-anchored and stay put during zoom, like
the in-cell references. Toggles with the "Grid > Lines" layer. */
id: 'grid_edge_labels',
type: 'symbol',
source: 'grid_labels',
minzoom: 13,
filter: ['!=', ['get', 'axis'], 'cell'],
layout: {
'text-field': ['get', 'label'],
'text-font': ['Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 14, 12, 18, 22],
'text-allow-overlap': true,
'text-ignore-placement': true,
},
paint: {
'text-color': '#333',
'text-halo-color': 'rgba(255, 255, 255, 0.9)',
'text-halo-width': 2,
},
},
{
/* Full grid reference (e.g. "F7") centred in every cell, from the generated
`grid_labels` points. Left to the collision engine (no allow-overlap) so
dense refs are thinned when zoomed out. Optional layer (gridref_ prefix). */
id: 'gridref_labels',
type: 'symbol',
source: 'grid_labels',
minzoom: 15,
filter: ['==', ['get', 'axis'], 'cell'],
layout: {
visibility: 'none',
'text-field': ['get', 'label'],
'text-font': ['Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 14, 11, 18, 18],
},
paint: {
'text-color': '#555',
'text-halo-color': 'rgba(255, 255, 255, 0.9)',
'text-halo-width': 2,
},
},
{
id: 'villages_symbol',
type: 'circle',
Expand Down
6 changes: 6 additions & 0 deletions web/src/style/map_style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ const style: StyleSpecification = {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
},
// Outer edge labels (A–L / 1–18), generated from the grid cells at runtime
// by setupGrid() so they sit just outside the grid and stay geo-anchored.
grid_labels: {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
},
search_results: {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
Expand Down