Lots of frontend changes
This commit is contained in:
parent
ec29631c44
commit
555ba7cf53
38 changed files with 1508 additions and 648 deletions
|
|
@ -1,25 +1,29 @@
|
|||
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||
|
||||
const INITIAL_RETRY_MS = 1000;
|
||||
const MAX_RETRY_MS = 10000;
|
||||
|
||||
// Error handling utilities
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError';
|
||||
}
|
||||
import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
|
||||
import pb from './pocketbase';
|
||||
|
||||
export function logNonAbortError(label: string, error: unknown): void {
|
||||
if (!isAbortError(error)) {
|
||||
console.error(`${label}:`, error);
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`${label}:`, error);
|
||||
}
|
||||
|
||||
export function authHeaders(init?: RequestInit): RequestInit {
|
||||
const headers: Record<string, string> = {};
|
||||
if (pb.authStore.isValid && pb.authStore.token) {
|
||||
headers['Authorization'] = `Bearer ${pb.authStore.token}`;
|
||||
}
|
||||
if (!init) return { headers };
|
||||
const existing = init.headers as Record<string, string> | undefined;
|
||||
return { ...init, headers: { ...existing, ...headers } };
|
||||
}
|
||||
|
||||
// API URL helper
|
||||
export function apiUrl(endpoint: string, params?: URLSearchParams): string {
|
||||
const base = getApiBaseUrl();
|
||||
const path = endpoint.startsWith('/') ? endpoint : `/api/${endpoint}`;
|
||||
const query = params?.toString();
|
||||
return query ? `${base}${path}?${query}` : `${base}${path}`;
|
||||
return query ? `${path}?${query}` : path;
|
||||
}
|
||||
|
||||
export async function fetchWithRetry<T>(
|
||||
|
|
@ -30,7 +34,7 @@ export async function fetchWithRetry<T>(
|
|||
let delay = INITIAL_RETRY_MS;
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
const res = await fetch(url, { signal });
|
||||
const res = await fetch(url, authHeaders({ signal }));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
onSuccess(json);
|
||||
|
|
@ -44,26 +48,6 @@ export async function fetchWithRetry<T>(
|
|||
}
|
||||
}
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const { pathname, href } = window.location;
|
||||
|
||||
const pathMatch = pathname.match(/^(\/proxy\/)(\d+)/);
|
||||
if (pathMatch) {
|
||||
return `${pathMatch[1]}8001`;
|
||||
}
|
||||
|
||||
const hrefMatch = href.match(/(\/proxy\/)\d+/);
|
||||
if (hrefMatch) {
|
||||
return `${hrefMatch[1]}8001`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildFilterString(filters: FeatureFilters, features: FeatureMeta[]): string {
|
||||
const entries = Object.entries(filters);
|
||||
if (entries.length === 0) return '';
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import type { ViewState } from '../types';
|
||||
|
||||
|
||||
export const INITIAL_RETRY_MS = 1000;
|
||||
export const MAX_RETRY_MS = 10000;
|
||||
|
||||
|
||||
export const MAP_BOUNDS: [number, number, number, number] = [-9.5, 49, 5, 57];
|
||||
export const MAP_MIN_ZOOM = 5.5;
|
||||
|
||||
/** Maximum zoom level for tile fetching (map extrapolates beyond this) */
|
||||
export const TILE_MAX_ZOOM = 15;
|
||||
|
||||
/** Initial map view state */
|
||||
export const INITIAL_VIEW_STATE: ViewState = {
|
||||
longitude: -1.5,
|
||||
latitude: 53.5,
|
||||
|
|
@ -27,14 +29,11 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
|
|||
{ maxZoom: 13, resolution: 9 },
|
||||
{ maxZoom: Infinity, resolution: 10 },
|
||||
] as const;
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 15;
|
||||
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 17.5;
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Color Gradients
|
||||
// =============================================================================
|
||||
|
||||
/** Feature value gradient (green → yellow → red → purple) */
|
||||
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [46, 204, 113] },
|
||||
{ t: 0.33, color: [241, 196, 15] },
|
||||
|
|
@ -42,34 +41,37 @@ export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[]
|
|||
{ t: 1, color: [142, 68, 173] },
|
||||
];
|
||||
|
||||
/** Property density gradient (teal → blue → purple) */
|
||||
/** Property density gradient — light mode (cream → orange) */
|
||||
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [130, 234, 220] },
|
||||
{ t: 0.5, color: [20, 140, 180] },
|
||||
{ t: 1, color: [88, 28, 140] },
|
||||
{ t: 0, color: [255, 255, 255] },
|
||||
{ t: 0.1, color: [248, 233, 211] },
|
||||
{ t: 0.5, color: [255, 221, 173] },
|
||||
{ t: 0.8, color: [251, 171, 60] },
|
||||
{ t: 1, color: [255, 162, 31] },
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// External URLs
|
||||
// =============================================================================
|
||||
/** Property density gradient — dark mode (dark warm → bright amber) */
|
||||
export const DENSITY_GRADIENT_DARK: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [55, 45, 35] },
|
||||
{ t: 0.1, color: [85, 65, 40] },
|
||||
{ t: 0.5, color: [170, 115, 50] },
|
||||
{ t: 0.8, color: [230, 155, 45] },
|
||||
{ t: 1, color: [255, 170, 40] },
|
||||
];
|
||||
|
||||
/** Protomaps font glyphs URL */
|
||||
export const GLYPHS_URL = 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf';
|
||||
/** Protomaps font glyphs URL (served locally from public/assets/) */
|
||||
export const GLYPHS_URL = '/assets/fonts/{fontstack}/{range}.pbf';
|
||||
|
||||
/** Protomaps sprite base URL */
|
||||
export const SPRITE_URL_BASE = 'https://protomaps.github.io/basemaps-assets/sprites/v4';
|
||||
/** Twemoji base URL (served locally from public/assets/) */
|
||||
export const TWEMOJI_BASE = '/assets/twemoji/';
|
||||
|
||||
/** Twemoji CDN base URL */
|
||||
export const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/';
|
||||
|
||||
/** OpenStreetMap attribution HTML */
|
||||
export const OSM_ATTRIBUTION = '© <a href="https://openstreetmap.org">OpenStreetMap</a>';
|
||||
|
||||
// =============================================================================
|
||||
// Stacked Chart Groups
|
||||
// =============================================================================
|
||||
|
||||
export interface StackedChartConfig {
|
||||
/**
|
||||
* Groups whose features should be collapsed into stacked bar charts.
|
||||
* Keyed by feature group name. Each entry defines one stacked chart.
|
||||
*/
|
||||
export const STACKED_GROUPS: Record<string, {
|
||||
/** Display label for the chart */
|
||||
label: string;
|
||||
/** If set, use this feature's stats for the total and info popup. Otherwise sum components. */
|
||||
|
|
@ -78,13 +80,7 @@ export interface StackedChartConfig {
|
|||
unit?: string;
|
||||
/** Feature names that make up the segments */
|
||||
components: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups whose features should be collapsed into stacked bar charts.
|
||||
* Keyed by feature group name. Each entry defines one stacked chart.
|
||||
*/
|
||||
export const STACKED_GROUPS: Record<string, StackedChartConfig[]> = {
|
||||
}[]> = {
|
||||
Crime: [
|
||||
{
|
||||
label: 'Serious crime',
|
||||
|
|
@ -124,6 +120,56 @@ export const STACKED_GROUPS: Record<string, StackedChartConfig[]> = {
|
|||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups whose enum features should be collapsed into compact multi-row charts.
|
||||
* Keyed by feature group name. Each entry defines one stacked enum chart.
|
||||
*/
|
||||
export const STACKED_ENUM_GROUPS: Record<string, {
|
||||
/** Display label for the chart */
|
||||
label: string;
|
||||
/** If set, use this feature for the info popup */
|
||||
feature?: string;
|
||||
/** Enum feature names that make up the rows */
|
||||
components: string[];
|
||||
/** Value order for consistent segment ordering */
|
||||
valueOrder: string[];
|
||||
/** Colors for each value (matches valueOrder) */
|
||||
valueColors: string[];
|
||||
}[]> = {
|
||||
Property: [
|
||||
{
|
||||
label: 'Property type',
|
||||
feature: 'Property type',
|
||||
components: ['Property type'],
|
||||
valueOrder: ['Detached', 'Semi-Detached', 'Terraced', 'Flat'],
|
||||
valueColors: ['#8b5cf6', '#3b82f6', '#14b8a6', '#f59e0b'],
|
||||
},
|
||||
{
|
||||
label: 'Leasehold/Freehold',
|
||||
feature: 'Leashold/Freehold',
|
||||
components: ['Leashold/Freehold'],
|
||||
valueOrder: ['Freehold', 'Leasehold'],
|
||||
valueColors: ['#3b82f6', '#f59e0b'],
|
||||
},
|
||||
],
|
||||
Environment: [
|
||||
{
|
||||
label: 'Ground Risk',
|
||||
feature: 'Environmental risk',
|
||||
components: [
|
||||
'Collapsible deposits risk',
|
||||
'Compressible ground risk',
|
||||
'Landslide risk',
|
||||
'Running sand risk',
|
||||
'Shrink-swell risk',
|
||||
'Soluble rocks risk',
|
||||
],
|
||||
valueOrder: ['Low', 'Moderate', 'Significant'],
|
||||
valueColors: ['#22c55e', '#eab308', '#ef4444'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Colors for stacked bar segments */
|
||||
export const SEGMENT_COLORS = [
|
||||
'#ef4444', // red-500
|
||||
|
|
|
|||
|
|
@ -1,10 +1,62 @@
|
|||
export function formatValue(value: number): string {
|
||||
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
||||
if (Number.isInteger(value)) return value.toLocaleString();
|
||||
return value.toFixed(1);
|
||||
export interface ValueFormat {
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
/** Show full integer (no k/M abbreviation) */
|
||||
raw?: boolean;
|
||||
}
|
||||
|
||||
export function formatValue(value: number, fmt?: ValueFormat): string {
|
||||
const p = fmt?.prefix ?? '';
|
||||
const s = fmt?.suffix ?? '';
|
||||
if (fmt?.raw) return `${p}${Math.round(value)}${s}`;
|
||||
if (Math.abs(value) >= 1_000_000) return `${p}${(value / 1_000_000).toFixed(1)}M${s}`;
|
||||
if (Math.abs(value) >= 1_000) return `${p}${(value / 1_000).toFixed(1)}k${s}`;
|
||||
if (Number.isInteger(value)) return `${p}${value.toLocaleString()}${s}`;
|
||||
return `${p}${value.toFixed(1)}${s}`;
|
||||
}
|
||||
|
||||
/** Lookup table for feature-specific formatting */
|
||||
export const FEATURE_FORMATS: Record<string, ValueFormat> = {
|
||||
// Property
|
||||
'Last known price': { prefix: '£' },
|
||||
'Price per sqm': { prefix: '£' },
|
||||
'Total floor area (sqm)': { suffix: ' sqm' },
|
||||
'Number of bedrooms & living rooms': { suffix: ' rooms' },
|
||||
'Transaction year': { raw: true },
|
||||
'Construction age': { raw: true },
|
||||
// Transport
|
||||
'Public transport to Bank (mins)': { suffix: ' mins' },
|
||||
'Public transport to Fitzrovia (mins)': { suffix: ' mins' },
|
||||
'Cycling to Bank (mins)': { suffix: ' mins' },
|
||||
'Cycling to Fitzrovia (mins)': { suffix: ' mins' },
|
||||
// Crime
|
||||
'Anti-social behaviour (avg/yr)': { suffix: '/yr' },
|
||||
'Violence and sexual offences (avg/yr)': { suffix: '/yr' },
|
||||
'Criminal damage and arson (avg/yr)': { suffix: '/yr' },
|
||||
'Burglary (avg/yr)': { suffix: '/yr' },
|
||||
'Vehicle crime (avg/yr)': { suffix: '/yr' },
|
||||
'Robbery (avg/yr)': { suffix: '/yr' },
|
||||
'Other theft (avg/yr)': { suffix: '/yr' },
|
||||
'Shoplifting (avg/yr)': { suffix: '/yr' },
|
||||
'Drugs (avg/yr)': { suffix: '/yr' },
|
||||
'Possession of weapons (avg/yr)': { suffix: '/yr' },
|
||||
'Public order (avg/yr)': { suffix: '/yr' },
|
||||
'Bicycle theft (avg/yr)': { suffix: '/yr' },
|
||||
'Theft from the person (avg/yr)': { suffix: '/yr' },
|
||||
'Other crime (avg/yr)': { suffix: '/yr' },
|
||||
'Serious crime (avg/yr)': { suffix: '/yr' },
|
||||
'Minor crime (avg/yr)': { suffix: '/yr' },
|
||||
// Demographics
|
||||
'% White': { suffix: '%' },
|
||||
'% Asian': { suffix: '%' },
|
||||
'% Black': { suffix: '%' },
|
||||
'% Mixed': { suffix: '%' },
|
||||
'% Other': { suffix: '%' },
|
||||
// Environment
|
||||
'Noise (dB)': { suffix: ' dB' },
|
||||
'Max available download speed (Mbps)': { suffix: ' Mbps', raw: true },
|
||||
};
|
||||
|
||||
export function formatFilterValue(value: number): string {
|
||||
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
||||
|
|
@ -29,20 +81,31 @@ export function formatNumber(value: number | undefined, decimals = 0): string {
|
|||
return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString();
|
||||
}
|
||||
|
||||
// Calculate weighted mean from histogram
|
||||
// Calculate weighted mean from histogram with outlier bins.
|
||||
// Bin 0 = [min, p1), bins 1..n-2 = [p1, p99) evenly, bin n-1 = [p99, max].
|
||||
export function calculateHistogramMean(histogram: {
|
||||
min: number;
|
||||
bin_width: number;
|
||||
max: number;
|
||||
p1: number;
|
||||
p99: number;
|
||||
counts: number[];
|
||||
}): number | undefined {
|
||||
if (!histogram.counts.length) return undefined;
|
||||
const n = histogram.counts.length;
|
||||
if (n === 0) return undefined;
|
||||
const totalCount = histogram.counts.reduce((a, b) => a + b, 0);
|
||||
if (totalCount === 0) return undefined;
|
||||
|
||||
const { min, max, p1, p99 } = histogram;
|
||||
const middleBins = Math.max(n - 2, 0);
|
||||
const middleWidth = middleBins > 0 && p99 > p1 ? (p99 - p1) / middleBins : 0;
|
||||
|
||||
let weightedSum = 0;
|
||||
for (let i = 0; i < histogram.counts.length; i++) {
|
||||
const binCenter = histogram.min + (i + 0.5) * histogram.bin_width;
|
||||
weightedSum += binCenter * histogram.counts[i];
|
||||
for (let i = 0; i < n; i++) {
|
||||
let center: number;
|
||||
if (i === 0) center = (min + p1) / 2;
|
||||
else if (i === n - 1) center = (p99 + max) / 2;
|
||||
else center = p1 + (i - 0.5) * middleWidth;
|
||||
weightedSum += center * histogram.counts[i];
|
||||
}
|
||||
return weightedSum / totalCount;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,40 +3,94 @@ import type { StyleSpecification } from 'maplibre-gl';
|
|||
import { layers, namedFlavor } from '@protomaps/basemaps';
|
||||
import {
|
||||
GLYPHS_URL,
|
||||
SPRITE_URL_BASE,
|
||||
TILE_MAX_ZOOM,
|
||||
OSM_ATTRIBUTION,
|
||||
FEATURE_GRADIENT,
|
||||
DENSITY_GRADIENT,
|
||||
DENSITY_GRADIENT_DARK,
|
||||
ZOOM_TO_RESOLUTION_THRESHOLDS,
|
||||
TWEMOJI_BASE,
|
||||
POSTCODE_ZOOM_THRESHOLD,
|
||||
} from './consts';
|
||||
|
||||
// Re-export constants for backwards compatibility
|
||||
export { FEATURE_GRADIENT as GRADIENT, DENSITY_GRADIENT, POSTCODE_ZOOM_THRESHOLD } from './consts';
|
||||
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' });
|
||||
|
||||
// Reduce road layer opacity so hexagons are more visible
|
||||
const modifiedLayers = baseLayers.map((layer) => {
|
||||
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 } };
|
||||
}
|
||||
}
|
||||
return layer;
|
||||
});
|
||||
|
||||
return {
|
||||
version: 8,
|
||||
glyphs: GLYPHS_URL,
|
||||
sprite: `${SPRITE_URL_BASE}/${theme}`,
|
||||
sources: {
|
||||
protomaps: {
|
||||
type: 'vector',
|
||||
tiles: [tileUrl],
|
||||
maxzoom: TILE_MAX_ZOOM,
|
||||
attribution: OSM_ATTRIBUTION,
|
||||
maxzoom: POSTCODE_ZOOM_THRESHOLD,
|
||||
},
|
||||
},
|
||||
layers: layers('protomaps', flavor, { lang: 'en' }),
|
||||
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.7936177850 * m - 0.0040720468 * s,
|
||||
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
|
||||
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * 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.2914855480 * 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.7076147010 * 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;
|
||||
|
|
@ -46,11 +100,14 @@ function interpolateGradient(t: number, gradient: GradientStop[]): [number, numb
|
|||
const hi = gradient[i + 1];
|
||||
if (t >= lo.t && t <= hi.t) {
|
||||
const frac = (t - lo.t) / (hi.t - lo.t);
|
||||
return [
|
||||
Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac),
|
||||
Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac),
|
||||
Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac),
|
||||
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;
|
||||
|
|
@ -60,8 +117,8 @@ export function normalizedToColor(t: number): [number, number, number] {
|
|||
return interpolateGradient(t, FEATURE_GRADIENT);
|
||||
}
|
||||
|
||||
export function countToColor(t: number): [number, number, number] {
|
||||
return interpolateGradient(t, DENSITY_GRADIENT);
|
||||
export function countToColor(t: number, gradient: GradientStop[] = DENSITY_GRADIENT): [number, number, number] {
|
||||
return interpolateGradient(t, gradient);
|
||||
}
|
||||
|
||||
export function zoomToResolution(zoom: number): number {
|
||||
|
|
|
|||
5
frontend/src/lib/pocketbase.ts
Normal file
5
frontend/src/lib/pocketbase.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import PocketBase from 'pocketbase';
|
||||
|
||||
const pb = new PocketBase('/pb');
|
||||
|
||||
export default pb;
|
||||
7
frontend/src/lib/utils.ts
Normal file
7
frontend/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Converts a gradient definition to CSS linear-gradient string
|
||||
*/
|
||||
export function gradientToCss(gradient: { t: number; color: [number, number, number] }[]): string {
|
||||
const stops = gradient.map(({ t, color }) => `rgb(${color.join(',')}) ${t * 100}%`).join(', ');
|
||||
return `linear-gradient(in oklch to right, ${stops})`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue