Optimisations
This commit is contained in:
parent
66c2a25457
commit
9179acd4cd
21 changed files with 653 additions and 139 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { trackPageview } from './usePlausible';
|
||||
import Map from './components/Map';
|
||||
import Filters from './components/Filters';
|
||||
import POIPane from './components/POIPane';
|
||||
|
|
@ -57,6 +58,11 @@ async function fetchWithRetry<T>(
|
|||
|
||||
// Detect if running through VS Code web proxy and construct API base URL
|
||||
function getApiBaseUrl(): string {
|
||||
// In production builds, always use same-origin (Rust server serves both API and frontend)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const { pathname, href } = window.location;
|
||||
|
||||
// Check pathname for /proxy/PORT pattern (VS Code web proxy)
|
||||
|
|
@ -71,7 +77,7 @@ function getApiBaseUrl(): string {
|
|||
return `${hrefMatch[1]}8001`;
|
||||
}
|
||||
|
||||
// Default: same origin (works for both local dev with webpack proxy and production)
|
||||
// Default: same origin (works for local dev with webpack proxy)
|
||||
return '';
|
||||
}
|
||||
|
||||
|
|
@ -417,6 +423,7 @@ export default function App() {
|
|||
const url = hash ? `${window.location.pathname}${window.location.search}#${hash}` : `${window.location.pathname}${window.location.search}`;
|
||||
window.history.pushState({ page }, '', url);
|
||||
setActivePage(page);
|
||||
trackPageview();
|
||||
}, []);
|
||||
|
||||
// Handle browser back/forward
|
||||
|
|
@ -465,8 +472,9 @@ export default function App() {
|
|||
// Derive view feature: active drag takes priority over pinned
|
||||
const viewFeature = activeFeature || pinnedFeature;
|
||||
const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null;
|
||||
// Color range: always the feature's full slider range from metadata
|
||||
// For enum features, use ordinal index range [0, values.length - 1]
|
||||
// Color range: use the filter slider range when a numeric filter is active,
|
||||
// otherwise fall back to the feature's full range from metadata.
|
||||
// For enum features, use ordinal index range [0, values.length - 1].
|
||||
const colorRange = useMemo((): [number, number] | null => {
|
||||
if (!viewFeature) return null;
|
||||
const meta = features.find((f) => f.name === viewFeature);
|
||||
|
|
@ -474,9 +482,13 @@ export default function App() {
|
|||
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
|
||||
return [0, meta.values.length - 1];
|
||||
}
|
||||
// Use live drag values or committed filter range if available
|
||||
if (activeFeature === viewFeature && dragValue) return dragValue;
|
||||
const filterVal = filters[viewFeature];
|
||||
if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number];
|
||||
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
|
||||
return null;
|
||||
}, [viewFeature, features]);
|
||||
}, [viewFeature, features, activeFeature, dragValue, filters]);
|
||||
|
||||
// Filter range: current drag or committed filter values, used for gray-out
|
||||
const filterRange = useMemo((): [number, number] | null => {
|
||||
|
|
@ -1087,6 +1099,22 @@ export default function App() {
|
|||
onHoverModeChange={setHoverMode}
|
||||
onViewProperties={handleViewPropertiesFromArea}
|
||||
onClose={handleCloseProperties}
|
||||
hexagonLocation={
|
||||
(() => {
|
||||
const hexId = hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
|
||||
? hoveredHexagon
|
||||
: selectedHexagon?.h3;
|
||||
const hex = hexId ? data.find((d) => d.h3 === hexId) : null;
|
||||
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
|
||||
return {
|
||||
lat: hex.lat as number,
|
||||
lon: hex.lon as number,
|
||||
postcode: (hex.postcode as string | undefined) ?? null,
|
||||
resolution,
|
||||
};
|
||||
})()
|
||||
}
|
||||
filters={filters}
|
||||
/>
|
||||
) : rightPaneTab === 'properties' ? (
|
||||
<PropertiesPane
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { FeatureMeta, HexagonStatsResponse } from '../types';
|
||||
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse } from '../types';
|
||||
|
||||
interface HexagonLocation {
|
||||
lat: number;
|
||||
lon: number;
|
||||
postcode: string | null;
|
||||
resolution: number;
|
||||
}
|
||||
|
||||
interface AreaPaneProps {
|
||||
stats: HexagonStatsResponse | null;
|
||||
|
|
@ -11,6 +18,8 @@ interface AreaPaneProps {
|
|||
onHoverModeChange: (enabled: boolean) => void;
|
||||
onViewProperties: () => void;
|
||||
onClose: () => void;
|
||||
hexagonLocation: HexagonLocation | null;
|
||||
filters: FeatureFilters;
|
||||
}
|
||||
|
||||
function formatValue(value: number): string {
|
||||
|
|
@ -37,10 +46,7 @@ function groupFeatures(
|
|||
return groups;
|
||||
}
|
||||
|
||||
function MiniHistogram({ counts, maxCount }: { counts: number[]; maxCount: number }) {
|
||||
if (maxCount === 0) return null;
|
||||
// Downsample to ~20 bars for display
|
||||
const targetBars = 20;
|
||||
function downsampleBars(counts: number[], targetBars: number): number[] {
|
||||
const step = Math.max(1, Math.floor(counts.length / targetBars));
|
||||
const bars: number[] = [];
|
||||
for (let index = 0; index < counts.length; index += step) {
|
||||
|
|
@ -50,21 +56,268 @@ function MiniHistogram({ counts, maxCount }: { counts: number[]; maxCount: numbe
|
|||
}
|
||||
bars.push(sum);
|
||||
}
|
||||
const barMax = Math.max(...bars, 1);
|
||||
return bars;
|
||||
}
|
||||
|
||||
function DualHistogram({
|
||||
localCounts,
|
||||
globalCounts,
|
||||
min,
|
||||
max,
|
||||
globalMean,
|
||||
}: {
|
||||
localCounts: number[];
|
||||
globalCounts: number[];
|
||||
min: number;
|
||||
max: number;
|
||||
globalMean?: number;
|
||||
}) {
|
||||
const targetBars = 25;
|
||||
const localBars = downsampleBars(localCounts, targetBars);
|
||||
const globalBars = downsampleBars(globalCounts, targetBars);
|
||||
|
||||
const barCount = Math.min(localBars.length, globalBars.length);
|
||||
const localMax = Math.max(...localBars, 1);
|
||||
const globalMax = Math.max(...globalBars, 1);
|
||||
|
||||
const meanFraction =
|
||||
globalMean != null && max > min ? (globalMean - min) / (max - min) : null;
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-px h-8 mt-1">
|
||||
{bars.map((count, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex-1 bg-teal-500 dark:bg-teal-400 rounded-t-sm min-w-[2px]"
|
||||
style={{ height: `${(count / barMax) * 100}%`, opacity: count > 0 ? 1 : 0.1 }}
|
||||
/>
|
||||
<div className="mt-1">
|
||||
<div className="relative flex items-end gap-px h-10">
|
||||
{Array.from({ length: barCount }).map((_, index) => {
|
||||
const globalHeight = (globalBars[index] / globalMax) * 100;
|
||||
const localHeight = (localBars[index] / localMax) * 100;
|
||||
return (
|
||||
<div key={index} className="flex-1 relative min-w-[2px] h-full flex items-end">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-warm-300/40 dark:bg-warm-600/40 rounded-t-sm"
|
||||
style={{ height: `${globalHeight}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-teal-500 dark:bg-teal-400 rounded-t-sm"
|
||||
style={{
|
||||
height: `${localHeight}%`,
|
||||
opacity: localBars[index] > 0 ? 1 : 0.1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{meanFraction != null && meanFraction >= 0 && meanFraction <= 1 && (
|
||||
<div
|
||||
className="absolute bottom-0 top-0 w-px border-l border-dashed border-warm-400 dark:border-warm-500"
|
||||
style={{ left: `${meanFraction * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonHistogram() {
|
||||
return (
|
||||
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2 animate-pulse">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<div className="h-3 w-24 bg-warm-200 dark:bg-warm-700 rounded" />
|
||||
<div className="h-3 w-10 bg-warm-200 dark:bg-warm-700 rounded" />
|
||||
</div>
|
||||
<div className="flex items-end gap-px h-10 mt-2">
|
||||
{Array.from({ length: 15 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-warm-200 dark:bg-warm-700 rounded-t-sm min-w-[2px]"
|
||||
style={{ height: `${20 + Math.sin(i * 0.7) * 30 + 30}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<div className="h-2 w-6 bg-warm-200 dark:bg-warm-700 rounded" />
|
||||
<div className="h-2 w-6 bg-warm-200 dark:bg-warm-700 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="p-3 space-y-4">
|
||||
{[0, 1, 2].map((groupIdx) => (
|
||||
<div key={groupIdx}>
|
||||
<div className="h-3 w-20 bg-warm-200 dark:bg-warm-700 rounded animate-pulse mb-2" />
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: groupIdx === 0 ? 3 : 2 }).map((_, i) => (
|
||||
<SkeletonHistogram key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Map app property types to each site's expected values
|
||||
const PROPERTY_TYPE_MAP: Record<string, { rightmove: string; onthemarket: string; zoopla: string }> = {
|
||||
'House': { rightmove: 'detached,semi-detached,terraced', onthemarket: 'property', zoopla: '' },
|
||||
'Detached': { rightmove: 'detached', onthemarket: 'detached', zoopla: 'detached' },
|
||||
'Semi-Detached': { rightmove: 'semi-detached', onthemarket: 'semi-detached', zoopla: 'semi_detached' },
|
||||
'Mid-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
||||
'End-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
||||
'Enclosed Mid-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
||||
'Enclosed End-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
|
||||
'Flat': { rightmove: 'flat', onthemarket: 'flats', zoopla: 'flat' },
|
||||
'Maisonette': { rightmove: 'flat', onthemarket: 'flats', zoopla: 'flat' },
|
||||
'Bungalow': { rightmove: 'bungalow', onthemarket: 'bungalow', zoopla: 'bungalow' },
|
||||
'Park home': { rightmove: 'park-home', onthemarket: 'property', zoopla: '' },
|
||||
};
|
||||
|
||||
// Approximate H3 hex edge length in miles by resolution
|
||||
// See https://h3geo.org/docs/core-library/restable
|
||||
const H3_RADIUS_MILES: Record<number, number> = {
|
||||
4: 15, // ~24km edge → ~15mi
|
||||
5: 6, // ~9km → ~6mi
|
||||
6: 3, // ~3.5km → ~3mi
|
||||
7: 1, // ~1.3km → ~1mi
|
||||
8: 0.5, // ~0.5km → ~0.3mi, round up
|
||||
9: 0.25, // ~0.17km
|
||||
10: 0.25, // ~0.07km
|
||||
11: 0.25, // ~0.025km
|
||||
12: 0.25,
|
||||
};
|
||||
|
||||
// Rightmove only accepts specific radius values
|
||||
const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
|
||||
// OnTheMarket and Zoopla accept similar sets
|
||||
const OTM_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
|
||||
const ZOOPLA_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 25, 30];
|
||||
|
||||
function nearestRadius(target: number, allowed: number[]): number {
|
||||
return allowed.reduce((best, r) => (Math.abs(r - target) < Math.abs(best - target) ? r : best));
|
||||
}
|
||||
|
||||
function buildPropertySearchUrls(
|
||||
location: HexagonLocation,
|
||||
filters: FeatureFilters
|
||||
): { rightmove: string; onthemarket: string; zoopla: string } {
|
||||
const { lat, lon, postcode, resolution } = location;
|
||||
const radiusMiles = H3_RADIUS_MILES[resolution] ?? 1;
|
||||
const coordStr = `${lat.toFixed(5)},${lon.toFixed(5)}`;
|
||||
|
||||
// Extract price filters
|
||||
const priceFilter = filters['Last known price'];
|
||||
const minPrice = Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
|
||||
const maxPrice = Array.isArray(priceFilter) && typeof priceFilter[1] === 'number' ? priceFilter[1] : undefined;
|
||||
|
||||
// Extract property type filters
|
||||
const propertyTypes = filters['Property type'];
|
||||
const selectedTypes = Array.isArray(propertyTypes) && typeof propertyTypes[0] === 'string' ? propertyTypes as string[] : [];
|
||||
|
||||
// --- Rightmove ---
|
||||
// Rightmove accepts both postcodes and lat,lon in searchLocation
|
||||
const rmParams = new URLSearchParams();
|
||||
rmParams.set('searchLocation', postcode || coordStr);
|
||||
rmParams.set('channel', 'BUY');
|
||||
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
|
||||
if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice)));
|
||||
if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice)));
|
||||
if (selectedTypes.length > 0) {
|
||||
const rmTypes = [...new Set(selectedTypes.flatMap((t) => {
|
||||
const mapped = PROPERTY_TYPE_MAP[t]?.rightmove;
|
||||
return mapped ? mapped.split(',') : [];
|
||||
}))];
|
||||
if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(','));
|
||||
}
|
||||
const rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
|
||||
|
||||
// --- OnTheMarket ---
|
||||
let otmType = 'property';
|
||||
if (selectedTypes.length > 0) {
|
||||
const otmTypes = [...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean))];
|
||||
if (otmTypes.length === 1 && otmTypes[0] !== 'property') otmType = otmTypes[0]!;
|
||||
}
|
||||
const otmParams = new URLSearchParams();
|
||||
otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII)));
|
||||
if (minPrice !== undefined) otmParams.set('min-price', String(Math.round(minPrice)));
|
||||
if (maxPrice !== undefined) otmParams.set('max-price', String(Math.round(maxPrice)));
|
||||
let onthemarket: string;
|
||||
if (postcode) {
|
||||
const slug = postcode.replace(/\s+/g, '-').toLowerCase();
|
||||
onthemarket = `https://www.onthemarket.com/for-sale/${otmType}/${slug}/?${otmParams.toString()}`;
|
||||
} else {
|
||||
// Use lat/lon search with geo params for bigger hexagons without a postcode
|
||||
otmParams.set('search-site', 'geo');
|
||||
otmParams.set('geo-lat', String(lat));
|
||||
otmParams.set('geo-lng', String(lon));
|
||||
onthemarket = `https://www.onthemarket.com/for-sale/${otmType}/?${otmParams.toString()}`;
|
||||
}
|
||||
|
||||
// --- Zoopla ---
|
||||
const zParams = new URLSearchParams();
|
||||
zParams.set('q', postcode || coordStr);
|
||||
zParams.set('search_source', 'for-sale');
|
||||
zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII)));
|
||||
if (minPrice !== undefined) zParams.set('price_min', String(Math.round(minPrice)));
|
||||
if (maxPrice !== undefined) zParams.set('price_max', String(Math.round(maxPrice)));
|
||||
if (selectedTypes.length > 0) {
|
||||
const zTypes = [...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.zoopla).filter(Boolean))];
|
||||
for (const zt of zTypes) {
|
||||
zParams.append('property_sub_type', zt!);
|
||||
}
|
||||
}
|
||||
let zoopla: string;
|
||||
if (postcode) {
|
||||
const slug = postcode.replace(/\s+/g, '-').toLowerCase();
|
||||
zoopla = `https://www.zoopla.co.uk/for-sale/property/${slug}/?${zParams.toString()}`;
|
||||
} else {
|
||||
// Use coordinate-based path for bigger hexagons
|
||||
zParams.set('geo_autocomplete_identifier', `geo_${lat}_${lon}`);
|
||||
zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`;
|
||||
}
|
||||
|
||||
return { rightmove, onthemarket, zoopla };
|
||||
}
|
||||
|
||||
function ExternalSearchLinks({ location, filters }: { location: HexagonLocation; filters: FeatureFilters }) {
|
||||
const urls = useMemo(() => buildPropertySearchUrls(location, filters), [location, filters]);
|
||||
const radiusMiles = H3_RADIUS_MILES[location.resolution] ?? 1;
|
||||
const label = location.postcode || `${radiusMiles}mi radius`;
|
||||
|
||||
return (
|
||||
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
|
||||
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
|
||||
Search {label} on
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={urls.rightmove}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
|
||||
>
|
||||
Rightmove
|
||||
</a>
|
||||
<a
|
||||
href={urls.onthemarket}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
|
||||
>
|
||||
OnTheMarket
|
||||
</a>
|
||||
<a
|
||||
href={urls.zoopla}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
|
||||
>
|
||||
Zoopla
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EnumBarChart({ counts }: { counts: Record<string, number> }) {
|
||||
const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA);
|
||||
const maxCount = Math.max(...entries.map(([, count]) => count), 1);
|
||||
|
|
@ -99,6 +352,8 @@ export default function AreaPane({
|
|||
onHoverModeChange,
|
||||
onViewProperties,
|
||||
onClose,
|
||||
hexagonLocation,
|
||||
filters,
|
||||
}: AreaPaneProps) {
|
||||
const featureGroups = useMemo(() => groupFeatures(globalFeatures), [globalFeatures]);
|
||||
|
||||
|
|
@ -113,6 +368,12 @@ export default function AreaPane({
|
|||
return new Map(stats.enum_features.map((feature) => [feature.name, feature]));
|
||||
}, [stats]);
|
||||
|
||||
// Build lookup for global feature metadata (for histogram overlay)
|
||||
const globalFeatureByName = useMemo(
|
||||
() => new Map(globalFeatures.map((f) => [f.name, f])),
|
||||
[globalFeatures]
|
||||
);
|
||||
|
||||
if (!hexagonId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-warm-500 dark:text-warm-400 px-4 text-center text-sm">
|
||||
|
|
@ -174,10 +435,13 @@ export default function AreaPane({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* External search links */}
|
||||
{hexagonLocation && stats && <ExternalSearchLinks location={hexagonLocation} filters={filters} />}
|
||||
|
||||
{/* Stats content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && !stats ? (
|
||||
<div className="p-4 text-warm-500 dark:text-warm-400 text-sm">Loading...</div>
|
||||
<LoadingSkeleton />
|
||||
) : stats ? (
|
||||
<div className="p-3 space-y-4">
|
||||
{featureGroups.map((group) => {
|
||||
|
|
@ -198,9 +462,24 @@ export default function AreaPane({
|
|||
const enumStats = enumByName.get(feature.name);
|
||||
|
||||
if (numericStats) {
|
||||
const maxCount = Math.max(...numericStats.histogram.counts);
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
const globalHistogram = globalFeature?.histogram;
|
||||
// Compute a global mean from the global histogram for the mean line
|
||||
let globalMean: number | undefined;
|
||||
if (globalHistogram && globalHistogram.counts.length > 0) {
|
||||
const totalCount = globalHistogram.counts.reduce((a, b) => a + b, 0);
|
||||
if (totalCount > 0) {
|
||||
let weightedSum = 0;
|
||||
for (let i = 0; i < globalHistogram.counts.length; i++) {
|
||||
const binCenter = globalHistogram.min + (i + 0.5) * globalHistogram.bin_width;
|
||||
weightedSum += binCenter * globalHistogram.counts[i];
|
||||
}
|
||||
globalMean = weightedSum / totalCount;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={feature.name} className="bg-warm-50 dark:bg-navy-800 rounded p-2">
|
||||
<div key={feature.name} className="bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||
{feature.name}
|
||||
|
|
@ -210,17 +489,32 @@ export default function AreaPane({
|
|||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
|
||||
<span>{formatValue(numericStats.min)}</span>
|
||||
<span>{formatValue(numericStats.max)}</span>
|
||||
<span>{formatValue(numericStats.histogram.min)}</span>
|
||||
<span>{formatValue(numericStats.histogram.max)}</span>
|
||||
</div>
|
||||
<MiniHistogram counts={numericStats.histogram.counts} maxCount={maxCount} />
|
||||
{globalHistogram ? (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={globalHistogram.counts}
|
||||
min={numericStats.histogram.min}
|
||||
max={numericStats.histogram.max}
|
||||
globalMean={globalMean}
|
||||
/>
|
||||
) : (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={numericStats.histogram.counts}
|
||||
min={numericStats.histogram.min}
|
||||
max={numericStats.histogram.max}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (enumStats) {
|
||||
return (
|
||||
<div key={feature.name} className="bg-warm-50 dark:bg-navy-800 rounded p-2">
|
||||
<div key={feature.name} className="bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{feature.name}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -654,6 +654,19 @@ export default memo(function Map({
|
|||
<DeckOverlay layers={layers} getTooltip={null} />
|
||||
</MapGL>
|
||||
<PostcodeSearch onFlyTo={handleFlyTo} />
|
||||
{viewSource === 'eye' && viewFeature && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-white dark:bg-warm-800 rounded-lg shadow-lg px-5 py-3">
|
||||
<span className="text-lg font-semibold text-navy-950 dark:text-warm-100">
|
||||
Previewing “{viewFeature}”
|
||||
</span>
|
||||
<button
|
||||
onClick={onCancelPin}
|
||||
className="px-4 py-1.5 rounded-md bg-warm-200 dark:bg-warm-700 text-warm-700 dark:text-warm-200 hover:bg-warm-300 dark:hover:bg-warm-600 font-medium text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{viewFeature && colorRange && colorFeatureMeta ? (
|
||||
<MapLegend
|
||||
featureLabel={colorFeatureMeta.name}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
import { initPlausible } from './usePlausible';
|
||||
|
||||
initPlausible();
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface FeatureMeta {
|
|||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
histogram?: { min: number; max: number; bin_width: number; counts: number[] };
|
||||
// Enum-only fields
|
||||
values?: string[];
|
||||
// Description fields
|
||||
|
|
|
|||
73
frontend/src/usePlausible.ts
Normal file
73
frontend/src/usePlausible.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
const DOMAIN = 'narrowit.schmelczer.dev';
|
||||
const ENDPOINT = '/status';
|
||||
|
||||
type EventOptions = {
|
||||
props?: Record<string, string | number | boolean>;
|
||||
revenue?: { currency: string; amount: number };
|
||||
};
|
||||
|
||||
function sendEvent(name: string, options?: EventOptions) {
|
||||
const payload: Record<string, unknown> = {
|
||||
n: name,
|
||||
u: window.location.href,
|
||||
d: DOMAIN,
|
||||
r: document.referrer || null,
|
||||
};
|
||||
if (options?.props) {
|
||||
payload.p = JSON.stringify(options.props);
|
||||
}
|
||||
if (options?.revenue) {
|
||||
payload.$ = JSON.stringify(options.revenue);
|
||||
}
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon(
|
||||
ENDPOINT,
|
||||
new Blob([JSON.stringify(payload)], { type: 'application/json' })
|
||||
);
|
||||
} else {
|
||||
fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* Tracks pageview on first call and returns a trackEvent function.
|
||||
* Tracks outbound link clicks automatically.
|
||||
*/
|
||||
export function initPlausible() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
// Initial pageview
|
||||
sendEvent('pageview');
|
||||
|
||||
// Track outbound link clicks
|
||||
document.addEventListener('click', (e) => {
|
||||
const link = (e.target as HTMLElement).closest?.('a');
|
||||
if (!link) return;
|
||||
const href = link.getAttribute('href');
|
||||
if (!href) return;
|
||||
try {
|
||||
const url = new URL(href, window.location.origin);
|
||||
if (url.hostname !== window.location.hostname) {
|
||||
sendEvent('Outbound Link: Click', { props: { url: href } });
|
||||
}
|
||||
} catch {
|
||||
// invalid URL, ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function trackPageview(options?: EventOptions) {
|
||||
sendEvent('pageview', options);
|
||||
}
|
||||
|
||||
export function trackEvent(name: string, options?: EventOptions) {
|
||||
sendEvent(name, options);
|
||||
}
|
||||
|
|
@ -51,6 +51,12 @@ module.exports = (env, argv) => {
|
|||
context: ['/api'],
|
||||
target: 'http://localhost:8001',
|
||||
},
|
||||
{
|
||||
context: ['/status'],
|
||||
target: 'https://stats.schmelczer.dev',
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/status': '/api/event' },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue