These work
This commit is contained in:
parent
3599803589
commit
1588c01b19
19 changed files with 260 additions and 201 deletions
|
|
@ -65,6 +65,81 @@ export function formatRelativeTime(isoDate: string): string {
|
|||
return new Date(isoDate).toLocaleDateString();
|
||||
}
|
||||
|
||||
// Percentile-based scale: maps between percentile space (0–100) and absolute values
|
||||
// using the histogram's CDF. Each percentile step = 1% of data.
|
||||
export interface PercentileScale {
|
||||
toValue: (percentile: number) => number;
|
||||
toPercentile: (value: number) => number;
|
||||
}
|
||||
|
||||
export function buildPercentileScale(hist: {
|
||||
min: number;
|
||||
max: number;
|
||||
p1: number;
|
||||
p99: number;
|
||||
counts: number[];
|
||||
}): PercentileScale {
|
||||
const n = hist.counts.length;
|
||||
const total = hist.counts.reduce((a, b) => a + b, 0);
|
||||
|
||||
if (n === 0 || total === 0) {
|
||||
const range = hist.max - hist.min || 1;
|
||||
return {
|
||||
toValue: (p) => hist.min + (p / 100) * range,
|
||||
toPercentile: (v) => ((v - hist.min) / range) * 100,
|
||||
};
|
||||
}
|
||||
|
||||
// Bin boundaries: [min, p1, ..middle edges.., p99, max]
|
||||
const boundaries: number[] = [];
|
||||
if (n === 1) {
|
||||
boundaries.push(hist.min, hist.max);
|
||||
} else {
|
||||
boundaries.push(hist.min, hist.p1);
|
||||
if (n > 2) {
|
||||
const middleWidth = (hist.p99 - hist.p1) / (n - 2);
|
||||
for (let i = 1; i < n - 1; i++) {
|
||||
boundaries.push(hist.p1 + i * middleWidth);
|
||||
}
|
||||
}
|
||||
boundaries.push(hist.max);
|
||||
}
|
||||
|
||||
// Cumulative fraction: cumFrac[0]=0, cumFrac[n]=1
|
||||
const cumFrac: number[] = [0];
|
||||
for (let i = 0; i < n; i++) {
|
||||
cumFrac.push(cumFrac[i] + hist.counts[i] / total);
|
||||
}
|
||||
cumFrac[n] = 1; // ensure exact 1.0
|
||||
|
||||
return {
|
||||
toValue(percentile: number): number {
|
||||
const target = Math.max(0, Math.min(1, percentile / 100));
|
||||
if (target <= 0) return boundaries[0];
|
||||
if (target >= 1) return boundaries[n];
|
||||
let i = 0;
|
||||
for (; i < n - 1; i++) {
|
||||
if (cumFrac[i + 1] > target) break;
|
||||
}
|
||||
const binFrac = cumFrac[i + 1] - cumFrac[i];
|
||||
const t = binFrac > 0 ? (target - cumFrac[i]) / binFrac : 0;
|
||||
return boundaries[i] + t * (boundaries[i + 1] - boundaries[i]);
|
||||
},
|
||||
|
||||
toPercentile(value: number): number {
|
||||
if (value <= boundaries[0]) return 0;
|
||||
if (value >= boundaries[n]) return 100;
|
||||
let i = 0;
|
||||
for (; i < n - 1; i++) {
|
||||
if (boundaries[i + 1] > value) break;
|
||||
}
|
||||
const binWidth = boundaries[i + 1] - boundaries[i];
|
||||
const t = binWidth > 0 ? (value - boundaries[i]) / binWidth : 0;
|
||||
return (cumFrac[i] + t * (cumFrac[i + 1] - cumFrac[i])) * 100;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 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: {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import type { Property } from '../types';
|
||||
|
||||
// Generic getter for any field names (for dynamic lookups)
|
||||
export function getNum(property: Property, ...keys: string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const v = property[key];
|
||||
if (v !== undefined && v !== null && typeof v === 'number') return v;
|
||||
}
|
||||
export function getNum(property: Property, key: string): number | undefined {
|
||||
const v = property[key];
|
||||
if (v !== undefined && v !== null && typeof v === 'number') return v;
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,30 +27,6 @@ function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
|
|||
return Object.keys(filters).length > 0 ? filters : undefined;
|
||||
}
|
||||
|
||||
/** Backward compat: parse old comma-packed `f` param */
|
||||
function parseLegacyFilters(f: string): FeatureFilters | undefined {
|
||||
const filters: FeatureFilters = {};
|
||||
for (const segment of f.split(',')) {
|
||||
const colonIdx = segment.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
const name = segment.substring(0, colonIdx);
|
||||
const rest = segment.substring(colonIdx + 1);
|
||||
if (rest.includes(':')) {
|
||||
const [minStr, maxStr] = rest.split(':');
|
||||
const min = Number(minStr);
|
||||
const max = Number(maxStr);
|
||||
if (!isNaN(min) && !isNaN(max)) {
|
||||
filters[name] = [min, max];
|
||||
}
|
||||
} else if (rest.includes('|')) {
|
||||
filters[name] = rest.split('|');
|
||||
} else {
|
||||
filters[name] = [rest];
|
||||
}
|
||||
}
|
||||
return Object.keys(filters).length > 0 ? filters : undefined;
|
||||
}
|
||||
|
||||
export function parseUrlState(): {
|
||||
viewState?: ViewState;
|
||||
filters?: FeatureFilters;
|
||||
|
|
@ -72,45 +48,21 @@ export function parseUrlState(): {
|
|||
if (!isNaN(latN) && !isNaN(lonN) && !isNaN(zoomN)) {
|
||||
result.viewState = { latitude: latN, longitude: lonN, zoom: zoomN, pitch: 0 };
|
||||
}
|
||||
} else {
|
||||
// Backward compat: old packed `v=lat,lon,zoom`
|
||||
const v = params.get('v');
|
||||
if (v) {
|
||||
const parts = v.split(',').map(Number);
|
||||
if (parts.length === 3 && parts.every((n) => !isNaN(n))) {
|
||||
result.viewState = { latitude: parts[0], longitude: parts[1], zoom: parts[2], pitch: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filters: repeated `filter` params
|
||||
result.filters = parseFilters(params);
|
||||
if (!result.filters) {
|
||||
// Backward compat: old packed `f` param
|
||||
const f = params.get('f');
|
||||
if (f) result.filters = parseLegacyFilters(f);
|
||||
}
|
||||
|
||||
// POI categories: repeated `poi` params
|
||||
const poiParams = params.getAll('poi');
|
||||
if (poiParams.length > 0) {
|
||||
// Handle both new (repeated params) and old (comma-separated) formats
|
||||
const categories = poiParams.flatMap((p) => p.split(',')).filter(Boolean);
|
||||
if (categories.length > 0) {
|
||||
result.poiCategories = new Set(categories);
|
||||
}
|
||||
result.poiCategories = new Set(poiParams.filter(Boolean));
|
||||
}
|
||||
|
||||
// Tab: full name
|
||||
const tab = params.get('tab');
|
||||
if (tab === 'properties' || tab === 'pois' || tab === 'area') {
|
||||
result.tab = tab;
|
||||
} else if (tab === 'p') {
|
||||
result.tab = 'properties'; // backward compat
|
||||
} else if (tab === 'o') {
|
||||
result.tab = 'pois';
|
||||
} else if (tab === 'a') {
|
||||
result.tab = 'area';
|
||||
}
|
||||
|
||||
// Travel time
|
||||
|
|
@ -121,7 +73,7 @@ export function parseUrlState(): {
|
|||
const tt: TravelTimeInitial = {
|
||||
destination: [parts[0], parts[1]],
|
||||
destinationLabel: params.get('destLabel') || '',
|
||||
mode: (params.get('tmode') as TransportMode) || 'transit',
|
||||
mode: (params.get('tmode') as TransportMode) || 'car',
|
||||
};
|
||||
const ttRange = params.get('tt');
|
||||
if (ttRange) {
|
||||
|
|
@ -178,7 +130,7 @@ export function stateToParams(
|
|||
if (travelTime.destinationLabel) {
|
||||
params.set('destLabel', travelTime.destinationLabel);
|
||||
}
|
||||
if (travelTime.mode !== 'transit') {
|
||||
if (travelTime.mode !== 'car') {
|
||||
params.set('tmode', travelTime.mode);
|
||||
}
|
||||
if (travelTime.timeRange) {
|
||||
|
|
@ -193,7 +145,6 @@ export function summarizeParams(queryString: string): string {
|
|||
const params = new URLSearchParams(queryString);
|
||||
const parts: string[] = [];
|
||||
|
||||
// New format: repeated `filter` params
|
||||
const filterParams = params.getAll('filter');
|
||||
if (filterParams.length > 0) {
|
||||
const filterNames = filterParams
|
||||
|
|
@ -207,28 +158,11 @@ export function summarizeParams(queryString: string): string {
|
|||
filterNames.length <= 2 ? filterNames.join(', ') : `${filterNames.length} filters`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Backward compat: old packed `f` param
|
||||
const f = params.get('f');
|
||||
if (f) {
|
||||
const filterNames = f
|
||||
.split(',')
|
||||
.map((seg) => {
|
||||
const colonIdx = seg.indexOf(':');
|
||||
return colonIdx > 0 ? seg.substring(0, colonIdx) : seg;
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (filterNames.length > 0) {
|
||||
parts.push(
|
||||
filterNames.length <= 2 ? filterNames.join(', ') : `${filterNames.length} filters`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const poiParams = params.getAll('poi');
|
||||
if (poiParams.length > 0) {
|
||||
const count = poiParams.flatMap((p) => p.split(',')).filter(Boolean).length;
|
||||
const count = poiParams.filter(Boolean).length;
|
||||
if (count > 0) {
|
||||
parts.push(`${count} POI ${count === 1 ? 'category' : 'categories'}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue