diff --git a/web/src/components/map.ts b/web/src/components/map.ts index 52e88d5..e11b859 100644 --- a/web/src/components/map.ts +++ b/web/src/components/map.ts @@ -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' @@ -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'), @@ -227,6 +228,7 @@ export class EMFMap extends LitElement { loadIcons(map) setupPhones(map) setupTracking(map) + setupGrid(map) map.touchZoomRotate.disableRotation() diff --git a/web/src/grid.ts b/web/src/grid.ts index e40404b..f61e4df 100644 --- a/web/src/grid.ts +++ b/web/src/grid.ts @@ -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 { @@ -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() + 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) + } +} diff --git a/web/src/style/emf.ts b/web/src/style/emf.ts index ac25888..c932b59 100644 --- a/web/src/style/emf.ts +++ b/web/src/style/emf.ts @@ -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', diff --git a/web/src/style/map_style.ts b/web/src/style/map_style.ts index 1b2511a..3b757ca 100644 --- a/web/src/style/map_style.ts +++ b/web/src/style/map_style.ts @@ -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: [] },