refactor
This commit is contained in:
@@ -16,7 +16,9 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
import * as echarts from 'echarts';
|
||||
import { echarts } from './echarts';
|
||||
|
||||
import type { ECharts } from 'echarts';
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -36,7 +38,7 @@
|
||||
/** Optional CSS class for the outer container */
|
||||
class?: string;
|
||||
/** Callback fired once the chart instance is initialized */
|
||||
onChartReady?: (chart: echarts.ECharts) => void;
|
||||
onChartReady?: (chart: ECharts) => void;
|
||||
/** Callback fired when the chart is disposed */
|
||||
onChartDisposed?: () => void;
|
||||
}
|
||||
@@ -56,7 +58,7 @@
|
||||
// ─── Internal State ─────────────────────────────────────────────────────────
|
||||
|
||||
let containerEl: HTMLDivElement;
|
||||
let chartInstance: echarts.ECharts | null = null;
|
||||
let chartInstance: ECharts | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||
@@ -64,7 +66,7 @@
|
||||
/**
|
||||
* Returns the underlying ECharts instance, or null if not yet initialized.
|
||||
*/
|
||||
export function getChart(): echarts.ECharts | null {
|
||||
export function getChart(): ECharts | null {
|
||||
return chartInstance;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Tree-shaken ECharts build: only the pieces the app actually renders (line
|
||||
// and bar series with grid/tooltip/legend/dataZoom/mark/graphic features) are
|
||||
// registered, instead of the ~1 MB full bundle.
|
||||
import { BarChart, LineChart } from 'echarts/charts';
|
||||
import {
|
||||
DataZoomInsideComponent,
|
||||
DataZoomSliderComponent,
|
||||
GraphicComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
MarkAreaComponent,
|
||||
MarkLineComponent,
|
||||
TitleComponent,
|
||||
TooltipComponent
|
||||
} from 'echarts/components';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
|
||||
echarts.use([
|
||||
LineChart,
|
||||
BarChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
DataZoomInsideComponent,
|
||||
DataZoomSliderComponent,
|
||||
MarkLineComponent,
|
||||
MarkAreaComponent,
|
||||
GraphicComponent,
|
||||
CanvasRenderer
|
||||
]);
|
||||
|
||||
export { echarts };
|
||||
@@ -64,17 +64,19 @@
|
||||
return {
|
||||
results: [
|
||||
{
|
||||
id: 100000000 + Math.floor(latitude * 100 + longitude + 1000),
|
||||
// coordinate-only location: id 0 + COORD makes
|
||||
// buildLocationRoute emit a "52.52N13.41E" route
|
||||
id: 0,
|
||||
name: `GPS ${latitude.toFixed(2)}°N ${longitude.toFixed(2)}°E`,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
elevation: position.coords.altitude ?? NaN,
|
||||
feature_code: '',
|
||||
elevation: position.coords.altitude ?? 0,
|
||||
feature_code: 'COORD',
|
||||
country_code: undefined,
|
||||
admin1_id: undefined,
|
||||
admin3_id: undefined,
|
||||
admin4_id: undefined,
|
||||
timezone: '',
|
||||
timezone: 'UTC',
|
||||
population: undefined,
|
||||
postcodes: undefined,
|
||||
country_id: undefined,
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
export {
|
||||
fetchWeekForecast,
|
||||
fetchModelComparison,
|
||||
fetchEnsembleForecast,
|
||||
range,
|
||||
getTimestamps,
|
||||
getDates,
|
||||
getValues,
|
||||
getInt64Values,
|
||||
unitToDisplayString
|
||||
} from './weather';
|
||||
|
||||
export type {
|
||||
WeatherLocation,
|
||||
WeatherUnitParams,
|
||||
MarkArea,
|
||||
WeekForecastParams,
|
||||
WeekHourlyData,
|
||||
WeekDailyData,
|
||||
WeekForecastResult,
|
||||
ModelCompareParams,
|
||||
ModelSeriesData,
|
||||
ModelCompareResult,
|
||||
EnsembleForecastParams,
|
||||
EnsembleVariableData,
|
||||
EnsembleForecastResult
|
||||
} from './weather';
|
||||
+84
-68
@@ -1,13 +1,21 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
|
||||
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
|
||||
import type { GeoLocation } from '$lib/stores/settings';
|
||||
|
||||
export const geoLocationNameToRoute = (name: string) => {
|
||||
const lowerCase = name.toLowerCase().replaceAll(' ', '-');
|
||||
return lowerCase.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||
};
|
||||
|
||||
// coordinate routes look like "52.52N13.41E" (negative values for S/W); GPS
|
||||
// selections navigate here directly, no geocoding id involved
|
||||
const COORD_ROUTE = /^(-?\d+(?:\.\d+)?)N(-?\d+(?:\.\d+)?)E$/i;
|
||||
|
||||
export function buildLocationRoute(location: GeoLocation): string {
|
||||
// coordinate-only locations (GPS) have no real geocoding id
|
||||
if (location.feature_code === 'COORD' || !location.id) {
|
||||
return `${location.latitude.toFixed(4)}N${location.longitude.toFixed(4)}E`;
|
||||
}
|
||||
const locationRoute = geoLocationNameToRoute(location.name);
|
||||
if (location.population && location.population > 543000) {
|
||||
return locationRoute;
|
||||
@@ -15,6 +23,41 @@ export function buildLocationRoute(location: GeoLocation): string {
|
||||
return locationRoute + '_' + location.id;
|
||||
}
|
||||
|
||||
export const coordinateLocation = (latitude: number, longitude: number): GeoLocation => ({
|
||||
id: 0,
|
||||
name: `${latitude.toFixed(2)}°N ${longitude.toFixed(2)}°E`,
|
||||
latitude,
|
||||
longitude,
|
||||
elevation: 0,
|
||||
feature_code: 'COORD',
|
||||
country_code: undefined,
|
||||
admin1_id: undefined,
|
||||
admin3_id: undefined,
|
||||
admin4_id: undefined,
|
||||
timezone: 'UTC',
|
||||
population: undefined,
|
||||
postcodes: undefined,
|
||||
country_id: undefined,
|
||||
country: undefined,
|
||||
admin1: undefined,
|
||||
admin3: undefined,
|
||||
admin4: undefined
|
||||
});
|
||||
|
||||
// the geocoding API response is untrusted input: it can be an error object or
|
||||
// (with a crafted URL) something else entirely, so the shape is checked before
|
||||
// anything downstream dereferences it
|
||||
const isGeoLocation = (value: unknown): value is GeoLocation => {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof candidate.name === 'string' &&
|
||||
typeof candidate.id === 'number' &&
|
||||
Number.isFinite(candidate.latitude) &&
|
||||
Number.isFinite(candidate.longitude)
|
||||
);
|
||||
};
|
||||
|
||||
interface ResolveLocationOptions {
|
||||
urlLocation: string;
|
||||
routePrefix: string;
|
||||
@@ -29,80 +72,53 @@ export async function resolveLocationFromRoute({
|
||||
routePrefix,
|
||||
event
|
||||
}: ResolveLocationOptions): Promise<GeoLocation> {
|
||||
let location: GeoLocation;
|
||||
const coordMatch = urlLocation.match(COORD_ROUTE);
|
||||
if (coordMatch) {
|
||||
return coordinateLocation(parseFloat(coordMatch[1]), parseFloat(coordMatch[2]));
|
||||
}
|
||||
|
||||
if (urlLocation.includes('N') && urlLocation.includes('E')) {
|
||||
const parts = urlLocation.split(/N|E/);
|
||||
const latitude = parseFloat(parts[0]);
|
||||
const longitude = parseFloat(parts[1]);
|
||||
let urlLocationName: string;
|
||||
let urlLocationId: string | undefined;
|
||||
|
||||
location = {
|
||||
id: 0,
|
||||
name: `${latitude}N° ${longitude}E°`,
|
||||
latitude,
|
||||
longitude,
|
||||
elevation: 0,
|
||||
feature_code: 'COORD',
|
||||
country_code: undefined,
|
||||
admin1_id: undefined,
|
||||
admin3_id: undefined,
|
||||
admin4_id: undefined,
|
||||
timezone: 'UTC',
|
||||
population: undefined,
|
||||
postcodes: undefined,
|
||||
country_id: undefined,
|
||||
country: undefined,
|
||||
admin1: undefined,
|
||||
admin3: undefined,
|
||||
admin4: undefined
|
||||
};
|
||||
if (urlLocation.includes('_')) {
|
||||
const split = urlLocation.split('_');
|
||||
urlLocationName = split[0];
|
||||
urlLocationId = split[1];
|
||||
} else if (/^\d+$/.test(urlLocation)) {
|
||||
urlLocationName = '';
|
||||
urlLocationId = urlLocation;
|
||||
} else {
|
||||
let urlLocationName: string;
|
||||
let urlLocationId: string | undefined;
|
||||
urlLocationName = urlLocation.includes('-') ? urlLocation.replace(/-/g, ' ') : urlLocation;
|
||||
urlLocationId = undefined;
|
||||
}
|
||||
|
||||
if (urlLocation.includes('_')) {
|
||||
const split = urlLocation.split('_');
|
||||
urlLocationName = split[0];
|
||||
urlLocationId = split[1];
|
||||
} else if (urlLocation.includes('-')) {
|
||||
urlLocationName = urlLocation.replace(/-/g, ' ');
|
||||
} else if (/^\d+$/.test(urlLocation)) {
|
||||
urlLocationName = '';
|
||||
urlLocationId = urlLocation;
|
||||
} else {
|
||||
urlLocationName = urlLocation;
|
||||
urlLocationId = undefined;
|
||||
}
|
||||
let location: GeoLocation;
|
||||
|
||||
if (urlLocationId) {
|
||||
const res = await event.fetch(
|
||||
`https://geocoding-api.open-meteo.com/v1/get?id=${urlLocationId}`
|
||||
);
|
||||
location = await res.json();
|
||||
} else {
|
||||
const res = await event.fetch(
|
||||
`https://geocoding-api.open-meteo.com/v1/search?name=${urlLocationName}&count=1&language=en&format=json`
|
||||
);
|
||||
const geocodingResponse = await res.json();
|
||||
if (geocodingResponse.results) {
|
||||
location = geocodingResponse.results[0];
|
||||
} else {
|
||||
error(404, 'Location not found');
|
||||
}
|
||||
}
|
||||
|
||||
const locationRoute = geoLocationNameToRoute(location.name);
|
||||
const canonicalSuffix =
|
||||
location.population && location.population > 543000
|
||||
? locationRoute
|
||||
: locationRoute + '_' + location.id;
|
||||
const canonicalPath = `${routePrefix}${canonicalSuffix}`;
|
||||
// route params are attacker-controlled: ids must be numeric and names are
|
||||
// URL-encoded so nothing can be injected into the API query string
|
||||
if (urlLocationId && /^\d+$/.test(urlLocationId)) {
|
||||
const res = await event.fetch(
|
||||
`https://geocoding-api.open-meteo.com/v1/get?id=${encodeURIComponent(urlLocationId)}`
|
||||
);
|
||||
if (!res.ok) error(404, 'Location not found');
|
||||
const candidate = await res.json();
|
||||
if (!isGeoLocation(candidate)) error(404, 'Location not found');
|
||||
location = candidate;
|
||||
} else {
|
||||
const res = await event.fetch(
|
||||
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(urlLocationName)}&count=1&language=en&format=json`
|
||||
);
|
||||
if (!res.ok) error(404, 'Location not found');
|
||||
const geocodingResponse = await res.json();
|
||||
const candidate = geocodingResponse?.results?.[0];
|
||||
if (!isGeoLocation(candidate)) error(404, 'Location not found');
|
||||
location = candidate;
|
||||
}
|
||||
|
||||
if (event.url.pathname !== canonicalPath) {
|
||||
throw redirect(303, canonicalPath);
|
||||
}
|
||||
const canonicalPath = `${routePrefix}${buildLocationRoute(location)}`;
|
||||
if (event.url.pathname !== canonicalPath) {
|
||||
throw redirect(303, canonicalPath);
|
||||
}
|
||||
|
||||
storedLocation.set(location);
|
||||
return location;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user