202 lines
6.5 KiB
TypeScript
202 lines
6.5 KiB
TypeScript
import type { ViewState, Bounds } from '../types';
|
|
import type { StyleSpecification } from 'maplibre-gl';
|
|
import { layers, namedFlavor } from '@protomaps/basemaps';
|
|
import {
|
|
GLYPHS_URL,
|
|
FEATURE_GRADIENT,
|
|
DENSITY_GRADIENT,
|
|
ZOOM_TO_RESOLUTION_THRESHOLDS,
|
|
TWEMOJI_BASE,
|
|
BUFFER_MULTIPLIER,
|
|
} from './consts';
|
|
|
|
// Re-export constants for backwards compatibility
|
|
export {
|
|
FEATURE_GRADIENT as GRADIENT,
|
|
DENSITY_GRADIENT,
|
|
DENSITY_GRADIENT_DARK,
|
|
POSTCODE_ZOOM_THRESHOLD,
|
|
} from './consts';
|
|
|
|
const ROAD_OPACITY = 0.4;
|
|
|
|
export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
|
const flavor = namedFlavor(theme);
|
|
// Use absolute URL for tiles - required by MapLibre
|
|
const tileUrl = `${window.location.origin}/api/tiles/{z}/{x}/{y}`;
|
|
const baseLayers = layers('protomaps', flavor, { lang: 'en' });
|
|
const isDark = theme === 'dark';
|
|
|
|
// Reduce road layer opacity so hexagons are more visible
|
|
// In dark mode, make all text white with dark outline
|
|
const modifiedLayers = baseLayers
|
|
.filter((layer) => !layer.id.includes('buildings'))
|
|
.map((layer) => {
|
|
// Modify road opacity
|
|
if (layer.id.includes('roads_') || layer.id.includes('road_')) {
|
|
if (layer.type === 'line') {
|
|
return { ...layer, paint: { ...layer.paint, 'line-opacity': ROAD_OPACITY } };
|
|
} else if (layer.type === 'fill') {
|
|
return { ...layer, paint: { ...layer.paint, 'fill-opacity': ROAD_OPACITY } };
|
|
}
|
|
}
|
|
|
|
// Modify text colors in dark mode
|
|
if (isDark && layer.type === 'symbol' && layer.paint?.['text-color']) {
|
|
return {
|
|
...layer,
|
|
paint: {
|
|
...layer.paint,
|
|
'text-color': '#ffffff',
|
|
'text-halo-color': '#1a1a1a',
|
|
'text-halo-width': 1.5,
|
|
},
|
|
};
|
|
}
|
|
|
|
return layer;
|
|
});
|
|
|
|
return {
|
|
version: 8,
|
|
glyphs: GLYPHS_URL,
|
|
sources: {
|
|
protomaps: {
|
|
type: 'vector',
|
|
tiles: [tileUrl],
|
|
maxzoom: 15,
|
|
},
|
|
},
|
|
layers: modifiedLayers,
|
|
} as StyleSpecification;
|
|
}
|
|
|
|
type GradientStop = { t: number; color: [number, number, number] };
|
|
|
|
// Oklab color space for perceptually uniform interpolation
|
|
function srgbToLinear(c: number): number {
|
|
const v = c / 255;
|
|
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
}
|
|
|
|
function linearToSrgb(c: number): number {
|
|
const v = c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
|
return Math.round(Math.max(0, Math.min(255, v * 255)));
|
|
}
|
|
|
|
function rgbToOklab(rgb: [number, number, number]): [number, number, number] {
|
|
const r = srgbToLinear(rgb[0]);
|
|
const g = srgbToLinear(rgb[1]);
|
|
const b = srgbToLinear(rgb[2]);
|
|
|
|
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
|
|
return [
|
|
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
|
];
|
|
}
|
|
|
|
function oklabToRgb(lab: [number, number, number]): [number, number, number] {
|
|
const L = lab[0],
|
|
a = lab[1],
|
|
b = lab[2];
|
|
|
|
const l = Math.pow(L + 0.3963377774 * a + 0.2158037573 * b, 3);
|
|
const m = Math.pow(L - 0.1055613458 * a - 0.0638541728 * b, 3);
|
|
const s = Math.pow(L - 0.0894841775 * a - 1.291485548 * b, 3);
|
|
|
|
return [
|
|
linearToSrgb(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
|
linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
|
linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
|
|
];
|
|
}
|
|
|
|
function interpolateGradient(t: number, gradient: GradientStop[]): [number, number, number] {
|
|
if (t <= 0) return gradient[0].color;
|
|
if (t >= 1) return gradient[gradient.length - 1].color;
|
|
|
|
for (let i = 0; i < gradient.length - 1; i++) {
|
|
const lo = gradient[i];
|
|
const hi = gradient[i + 1];
|
|
if (t >= lo.t && t <= hi.t) {
|
|
const frac = (t - lo.t) / (hi.t - lo.t);
|
|
const loLab = rgbToOklab(lo.color);
|
|
const hiLab = rgbToOklab(hi.color);
|
|
const interpLab: [number, number, number] = [
|
|
loLab[0] + (hiLab[0] - loLab[0]) * frac,
|
|
loLab[1] + (hiLab[1] - loLab[1]) * frac,
|
|
loLab[2] + (hiLab[2] - loLab[2]) * frac,
|
|
];
|
|
return oklabToRgb(interpLab);
|
|
}
|
|
}
|
|
return gradient[gradient.length - 1].color;
|
|
}
|
|
|
|
export function normalizedToColor(t: number): [number, number, number] {
|
|
return interpolateGradient(t, FEATURE_GRADIENT);
|
|
}
|
|
|
|
export function countToColor(
|
|
t: number,
|
|
gradient: GradientStop[] = DENSITY_GRADIENT
|
|
): [number, number, number] {
|
|
return interpolateGradient(t, gradient);
|
|
}
|
|
|
|
export function zoomToResolution(zoom: number): number {
|
|
for (const { maxZoom, resolution } of ZOOM_TO_RESOLUTION_THRESHOLDS) {
|
|
if (zoom < maxZoom) return resolution;
|
|
}
|
|
return ZOOM_TO_RESOLUTION_THRESHOLDS[ZOOM_TO_RESOLUTION_THRESHOLDS.length - 1].resolution;
|
|
}
|
|
|
|
export function getBoundsFromViewState(
|
|
viewState: ViewState,
|
|
width: number,
|
|
height: number
|
|
): Bounds {
|
|
const { longitude, latitude, zoom } = viewState;
|
|
const clampedLat = Math.max(-85, Math.min(85, latitude));
|
|
const TILE_SIZE = 512;
|
|
const scale = Math.pow(2, zoom);
|
|
const worldSize = TILE_SIZE * scale;
|
|
|
|
const bufferedWidth = width * BUFFER_MULTIPLIER;
|
|
const bufferedHeight = height * BUFFER_MULTIPLIER;
|
|
|
|
const degreesPerPixelLng = 360 / worldSize;
|
|
const halfWidthDeg = (bufferedWidth / 2) * degreesPerPixelLng;
|
|
|
|
const latRad = (clampedLat * Math.PI) / 180;
|
|
const mercatorY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
|
|
const centerPixelY = mercatorY * worldSize;
|
|
|
|
const topPixelY = centerPixelY - bufferedHeight / 2;
|
|
const bottomPixelY = centerPixelY + bufferedHeight / 2;
|
|
|
|
const pixelYToLat = (pixelY: number): number => {
|
|
const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize));
|
|
const latRadians = Math.atan(Math.sinh(Math.PI * (1 - 2 * mercY)));
|
|
return (latRadians * 180) / Math.PI;
|
|
};
|
|
|
|
const north = Math.min(85, pixelYToLat(topPixelY));
|
|
const south = Math.max(-85, pixelYToLat(bottomPixelY));
|
|
const west = Math.max(-180, longitude - halfWidthDeg);
|
|
const east = Math.min(180, longitude + halfWidthDeg);
|
|
|
|
return { south, west, north, east };
|
|
}
|
|
|
|
export function emojiToTwemojiUrl(emoji: string): string {
|
|
const codePoint = emoji.codePointAt(0);
|
|
if (!codePoint) return `${TWEMOJI_BASE}1f4cd.png`;
|
|
const hex = codePoint.toString(16);
|
|
return `${TWEMOJI_BASE}${hex}.png`;
|
|
}
|