mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(tours): paddle portages and locks, paddle time, wind at the start and obstacle data from OpenStreetMap
PAD-03 portages and locks on planned tours and recorded tracks, PAD-07 paddle time with portage and lock stops, PAD-08 wind forecast for the start, PAD-05A regional obstacle import from Geofabrik extracts with admin endpoints and a monthly refresh, PAD-05B first cut of the obstacle layer on Leaflet, GL and 3D. Work in progress: checklist (PAD-06), check bench (PAD-09), admin UI, translations and tests follow.
This commit is contained in:
@@ -333,6 +333,121 @@ const tours: TranslationStrings = {
|
||||
'Ein Import ist eine aufgezeichnete Strecke. Plane eine bearbeitbare Paddeltour: TREK nimmt Kontrollpunkte daraus, verbindet sie mit geraden Linien, wie eine Paddeltour gezeichnet wird, und zeigt, wie nah das herankommt. Nichts ändert sich, bis du dich entscheidest.',
|
||||
'tours.conversion.closePaddle':
|
||||
'Der Vorschlag folgt der Strecke eng. Seine Punkte sind mit geraden Linien verbunden, daher ist es nicht dieselbe Linie.',
|
||||
'tours.planner.moreActions': 'Weitere Aktionen',
|
||||
'tours.paddle.role.put_in': 'Einstieg',
|
||||
'tours.paddle.role.take_out': 'Ausstieg',
|
||||
'tours.paddle.role.re_entry': 'Wiedereinstieg',
|
||||
'tours.paddle.role.final_take_out': 'Endausstieg',
|
||||
'tours.paddle.portage': 'Umtragen',
|
||||
'tours.paddle.portageOn': 'Ab hier umtragen',
|
||||
'tours.paddle.portageOff': 'Diese Strecke wieder paddeln',
|
||||
'tours.paddle.portageInfo':
|
||||
'Hier wird das Boot getragen. Die Strecke zählt als Land, mit Zeit für Aussteigen, Ausladen und Wiedereinsetzen.',
|
||||
'tours.paddle.portageExtra': 'Aus und ein',
|
||||
'tours.paddle.lock': 'Schleuse',
|
||||
'tours.paddle.lockAdd': 'Schleuse an diesem Punkt',
|
||||
'tours.paddle.lockRemove': 'Schleuse entfernen',
|
||||
'tours.paddle.lockWait': 'Warten',
|
||||
'tours.paddle.lockTransit': 'Durchfahrt',
|
||||
'tours.paddle.lockBypassed': 'Umtragen, es zählt die Portage',
|
||||
'tours.paddle.time.title': 'Zeitschätzung',
|
||||
'tours.paddle.time.assumption': 'Planannahmen',
|
||||
'tours.paddle.time.assumptionInfo':
|
||||
'TREK beginnt mit 4 km/h auf dem Wasser, 2 km/h beim Tragen und 10 Minuten zum Aus- und Einsetzen je Portage. Das sind Startwerte zum Anpassen, keine Norm und keine Messung. Schleusenzeiten sind nur, was du einträgst.',
|
||||
'tours.paddle.time.water': 'Auf dem Wasser',
|
||||
'tours.paddle.time.carry': 'Tragen',
|
||||
'tours.paddle.time.carrySpeed': 'Tragetempo',
|
||||
'tours.paddle.time.outIn': 'Aus- und Einsetzen',
|
||||
'tours.paddle.time.locks': 'Schleusen',
|
||||
'tours.paddle.time.estimate': 'Schätzung',
|
||||
'tours.paddle.time.incomplete': 'Unvollständig',
|
||||
'tours.paddle.time.plannedOwn': 'Deine geplante Dauer',
|
||||
'tours.paddle.time.noSpeed': 'Ohne Paddeltempo bleibt die Zeit auf dem Wasser offen.',
|
||||
'tours.paddle.time.locksMissing': '{count} Schleusen haben noch keine Zeiten.',
|
||||
'tours.paddle.time.locksMissing.one': 'Eine Schleuse hat noch keine Zeiten.',
|
||||
'tours.paddle.startValue': 'Startwert',
|
||||
'tours.paddle.timeMissing': 'Zeit fehlt',
|
||||
'tours.paddle.time.waterShort': 'Wasser',
|
||||
'tours.paddle.portageRemove': 'Portage entfernen',
|
||||
'tours.paddle.marks.title': 'Portagen und Schleusen',
|
||||
'tours.paddle.marks.markPortage': 'Portage',
|
||||
'tours.paddle.marks.markLock': 'Schleuse',
|
||||
'tours.paddle.marks.pickLock': 'Klicke auf der Linie, wo die Schleuse liegt.',
|
||||
'tours.paddle.marks.pickTakeOut': 'Klicke auf der Linie, wo du aussteigst.',
|
||||
'tours.paddle.marks.pickReEntry': 'Jetzt dort, wo du wieder einsetzt.',
|
||||
'tours.paddle.marks.overlap': 'Dort ist schon eine Portage markiert. Markiere die neue daneben.',
|
||||
'tours.paddle.marks.offLine': 'Das war zu weit von der Linie. Klicke direkt auf die Strecke.',
|
||||
'tours.paddle.marks.empty': 'Noch nichts markiert. Die aufgezeichnete Strecke bleibt, wie sie ist.',
|
||||
'tours.paddle.marks.at': 'bei {distance}',
|
||||
'tours.paddle.marks.saved': 'Portagen und Schleusen gespeichert',
|
||||
'tours.paddle.marks.failed': 'Speichern hat nicht geklappt. Bitte versuche es erneut.',
|
||||
'tours.paddle.wind.title': 'Wind',
|
||||
'tours.paddle.wind.hourInfo': '{hour} Uhr: {speed} km/h, Böen {gusts} km/h, aus {direction}',
|
||||
'tours.paddle.wind.hourUnknown': '{hour} Uhr: kein Wert',
|
||||
'tours.paddle.wind.error': 'Die Windvorhersage ist gerade nicht abrufbar.',
|
||||
'tours.paddle.wind.retry': 'Erneut versuchen',
|
||||
'tours.paddle.wind.offline': 'Ohne Verbindung gibt es keine Windvorhersage.',
|
||||
'tours.paddle.wind.beyond': 'Für diesen Tag gibt es noch keine Vorhersage. Sie kommt ab {date}.',
|
||||
'tours.paddle.wind.past': 'Dieser Tag liegt zurück. Eine Vorhersage gibt es nur für kommende Tage.',
|
||||
'tours.paddle.wind.no_day':
|
||||
'Ordne die Tour einem Reisetag zu, dann siehst du hier die Windvorhersage für diesen Tag.',
|
||||
'tours.paddle.wind.no_date': 'Dieser Reisetag hat kein Datum, deshalb gibt es keine Vorhersage.',
|
||||
'tours.paddle.wind.no_place': 'Die Tour hat noch keinen Startpunkt, für den die Vorhersage gelten könnte.',
|
||||
'tours.paddle.wind.hintsTitle': 'Was Wind beim Paddeln bedeutet',
|
||||
'tours.paddle.wind.dir.N': 'N',
|
||||
'tours.paddle.wind.dir.NNE': 'NNO',
|
||||
'tours.paddle.wind.dir.NE': 'NO',
|
||||
'tours.paddle.wind.dir.ENE': 'ONO',
|
||||
'tours.paddle.wind.dir.E': 'O',
|
||||
'tours.paddle.wind.dir.ESE': 'OSO',
|
||||
'tours.paddle.wind.dir.SE': 'SO',
|
||||
'tours.paddle.wind.dir.SSE': 'SSO',
|
||||
'tours.paddle.wind.dir.S': 'S',
|
||||
'tours.paddle.wind.dir.SSW': 'SSW',
|
||||
'tours.paddle.wind.dir.SW': 'SW',
|
||||
'tours.paddle.wind.dir.WSW': 'WSW',
|
||||
'tours.paddle.wind.dir.W': 'W',
|
||||
'tours.paddle.wind.dir.WNW': 'WNW',
|
||||
'tours.paddle.wind.dir.NW': 'NW',
|
||||
'tours.paddle.wind.dir.NNW': 'NNW',
|
||||
'tours.paddle.wind.legendMean': 'Mittelwind',
|
||||
'tours.paddle.wind.legendGusts': 'Böen',
|
||||
'tours.paddle.wind.mean': 'Mittel',
|
||||
'tours.paddle.wind.gusts': 'Böen',
|
||||
'tours.paddle.wind.fromInfo': 'Wind aus {direction}, die Richtung, aus der er den Tag über meist weht',
|
||||
'tours.paddle.wind.meanInfo': 'Der stärkste stündliche Mittelwind des Tages',
|
||||
'tours.paddle.wind.gustsInfo': 'Die stärkste Böe des Tages',
|
||||
'tours.paddle.wind.source': '{source}, Stand {time}, Vorhersage am Start',
|
||||
'tours.paddle.wind.hint.headwind': 'Gegenwind kostet Zeit und Kraft.',
|
||||
'tours.paddle.wind.hint.drift': 'Seitenwind drückt das Boot ab.',
|
||||
'tours.paddle.wind.hint.crossing': 'Offene Querungen sind ausgesetzter als die Ufernähe.',
|
||||
'tours.paddle.wind.hint.gusts': 'Böen sind stärker als der Mittelwind und kommen plötzlich.',
|
||||
'tours.paddle.wind.hint.local': 'Kein Wert ist eine Entwarnung. Prüfe die Lage vor Ort.',
|
||||
'tours.paddle.obstacles.loading': 'Hindernisse an der Strecke werden geladen …',
|
||||
'tours.paddle.obstacles.covered':
|
||||
'{count} Hindernisse und Ein- und Ausstiege an der Strecke, aus OpenStreetMap, Stand {date}. Die Daten können Lücken haben, prüfe das Gewässer selbst.',
|
||||
'tours.paddle.obstacles.covered.one':
|
||||
'{count} Hindernis oder Ein- und Ausstieg an der Strecke, aus OpenStreetMap, Stand {date}. Die Daten können Lücken haben, prüfe das Gewässer selbst.',
|
||||
'tours.paddle.obstacles.coveredEmpty':
|
||||
'Keine bekannten Hindernisse an der Strecke (OpenStreetMap, Stand {date}). Prüfe das Gewässer trotzdem selbst.',
|
||||
'tours.paddle.obstacles.partial':
|
||||
'Hindernisdaten decken nur einen Teil der Strecke ab (Stand {date}). Den Rest prüfst du selbst.',
|
||||
'tours.paddle.obstacles.none':
|
||||
'Für dieses Gebiet liegen keine Hindernisdaten vor. Wehre, Schleusen und Umtragestellen prüfst du hier selbst.',
|
||||
'tours.paddle.obstacles.offline': 'Ohne Verbindung lädt TREK keine Hindernisdaten. Prüfe das Gewässer selbst.',
|
||||
'tours.paddle.obstacles.error': 'Die Hindernisdaten ließen sich nicht laden. Prüfe das Gewässer selbst.',
|
||||
'tours.paddle.obstacle.kind.weir': 'Wehr',
|
||||
'tours.paddle.obstacle.kind.dam': 'Staudamm',
|
||||
'tours.paddle.obstacle.kind.lock': 'Schleuse',
|
||||
'tours.paddle.obstacle.kind.lock_gate': 'Schleusentor',
|
||||
'tours.paddle.obstacle.kind.waterfall': 'Wasserfall',
|
||||
'tours.paddle.obstacle.kind.rapids': 'Stromschnelle',
|
||||
'tours.paddle.obstacle.kind.hazard': 'Gefahrenstelle',
|
||||
'tours.paddle.obstacle.kind.put_in': 'Einstieg',
|
||||
'tours.paddle.obstacle.kind.egress': 'Ausstieg',
|
||||
'tours.paddle.obstacle.kind.slipway': 'Slipanlage',
|
||||
'tours.paddle.obstacle.kind.portage': 'Umtragestelle',
|
||||
'tours.paddle.obstacle.kind.access': 'Befahrungsregel',
|
||||
};
|
||||
|
||||
export default tours;
|
||||
|
||||
@@ -324,5 +324,118 @@ const tours: TranslationStrings = {
|
||||
'An import is a recorded track. Plan an editable paddle tour: TREK takes control points from it, joins them with straight lines as a paddle tour is drawn, and shows how close that comes. Nothing changes until you choose.',
|
||||
'tours.conversion.closePaddle':
|
||||
'The suggestion follows the track closely. Its points are joined by straight lines, so it is not the same line.',
|
||||
'tours.planner.moreActions': 'More actions',
|
||||
'tours.paddle.role.put_in': 'Put-in',
|
||||
'tours.paddle.role.take_out': 'Take-out',
|
||||
'tours.paddle.role.re_entry': 'Re-entry',
|
||||
'tours.paddle.role.final_take_out': 'Final take-out',
|
||||
'tours.paddle.portage': 'Portage',
|
||||
'tours.paddle.portageOn': 'Carry the boat from here',
|
||||
'tours.paddle.portageOff': 'Paddle this stretch again',
|
||||
'tours.paddle.portageInfo':
|
||||
'The boat is carried here. The stretch counts as land, with time for getting out, unloading and putting back in.',
|
||||
'tours.paddle.portageExtra': 'Out and in',
|
||||
'tours.paddle.lock': 'Lock',
|
||||
'tours.paddle.lockAdd': 'Lock at this point',
|
||||
'tours.paddle.lockRemove': 'Remove the lock',
|
||||
'tours.paddle.lockWait': 'Wait',
|
||||
'tours.paddle.lockTransit': 'Passage',
|
||||
'tours.paddle.lockBypassed': 'Carried round, the portage counts',
|
||||
'tours.paddle.time.title': 'Time estimate',
|
||||
'tours.paddle.time.assumption': 'Planning values',
|
||||
'tours.paddle.time.assumptionInfo':
|
||||
'TREK starts with 4 km/h on the water, 2 km/h carrying and 10 minutes to get out and back in at each portage. These are starting values to adjust, not norms and not measurements. Lock times are only what you enter.',
|
||||
'tours.paddle.time.water': 'On the water',
|
||||
'tours.paddle.time.carry': 'Carrying',
|
||||
'tours.paddle.time.carrySpeed': 'Carrying speed',
|
||||
'tours.paddle.time.outIn': 'Out and back in',
|
||||
'tours.paddle.time.locks': 'Locks',
|
||||
'tours.paddle.time.estimate': 'Estimate',
|
||||
'tours.paddle.time.incomplete': 'Incomplete',
|
||||
'tours.paddle.time.plannedOwn': 'Your planned total',
|
||||
'tours.paddle.time.noSpeed': 'Without a paddling speed the time on the water stays open.',
|
||||
'tours.paddle.time.locksMissing': '{count} locks have no times yet.',
|
||||
'tours.paddle.time.locksMissing.one': 'One lock has no times yet.',
|
||||
'tours.paddle.startValue': 'Starting value',
|
||||
'tours.paddle.timeMissing': 'Time missing',
|
||||
'tours.paddle.time.waterShort': 'Water',
|
||||
'tours.paddle.portageRemove': 'Remove the portage',
|
||||
'tours.paddle.marks.title': 'Portages and locks',
|
||||
'tours.paddle.marks.markPortage': 'Portage',
|
||||
'tours.paddle.marks.markLock': 'Lock',
|
||||
'tours.paddle.marks.pickLock': 'Click the line where the lock is.',
|
||||
'tours.paddle.marks.pickTakeOut': 'Click the line where you get out.',
|
||||
'tours.paddle.marks.pickReEntry': 'Now click where you put back in.',
|
||||
'tours.paddle.marks.overlap': 'A portage is already marked there. Mark the new one beside it.',
|
||||
'tours.paddle.marks.offLine': 'That was too far from the line. Click right on the track.',
|
||||
'tours.paddle.marks.empty': 'Nothing marked yet. The recorded track stays as it is.',
|
||||
'tours.paddle.marks.at': 'at {distance}',
|
||||
'tours.paddle.marks.saved': 'Portages and locks saved',
|
||||
'tours.paddle.marks.failed': 'Saving did not work. Please try again.',
|
||||
'tours.paddle.wind.title': 'Wind',
|
||||
'tours.paddle.wind.hourInfo': '{hour}:00: {speed} km/h, gusts {gusts} km/h, from {direction}',
|
||||
'tours.paddle.wind.hourUnknown': '{hour}:00: no value',
|
||||
'tours.paddle.wind.error': 'The wind forecast cannot be fetched right now.',
|
||||
'tours.paddle.wind.retry': 'Try again',
|
||||
'tours.paddle.wind.offline': 'Without a connection there is no wind forecast.',
|
||||
'tours.paddle.wind.beyond': 'No forecast for this day yet. It comes from {date}.',
|
||||
'tours.paddle.wind.past': 'This day has passed. A forecast exists only for days to come.',
|
||||
'tours.paddle.wind.no_day': 'Put the tour on a day of the trip to see the wind forecast for that day.',
|
||||
'tours.paddle.wind.no_date': 'This day of the trip has no date, so there is no forecast for it.',
|
||||
'tours.paddle.wind.no_place': 'The tour has no start point yet to ask the forecast for.',
|
||||
'tours.paddle.wind.hintsTitle': 'What wind means for paddling',
|
||||
'tours.paddle.wind.dir.N': 'N',
|
||||
'tours.paddle.wind.dir.NNE': 'NNE',
|
||||
'tours.paddle.wind.dir.NE': 'NE',
|
||||
'tours.paddle.wind.dir.ENE': 'ENE',
|
||||
'tours.paddle.wind.dir.E': 'E',
|
||||
'tours.paddle.wind.dir.ESE': 'ESE',
|
||||
'tours.paddle.wind.dir.SE': 'SE',
|
||||
'tours.paddle.wind.dir.SSE': 'SSE',
|
||||
'tours.paddle.wind.dir.S': 'S',
|
||||
'tours.paddle.wind.dir.SSW': 'SSW',
|
||||
'tours.paddle.wind.dir.SW': 'SW',
|
||||
'tours.paddle.wind.dir.WSW': 'WSW',
|
||||
'tours.paddle.wind.dir.W': 'W',
|
||||
'tours.paddle.wind.dir.WNW': 'WNW',
|
||||
'tours.paddle.wind.dir.NW': 'NW',
|
||||
'tours.paddle.wind.dir.NNW': 'NNW',
|
||||
'tours.paddle.wind.legendMean': 'Mean wind',
|
||||
'tours.paddle.wind.legendGusts': 'Gusts',
|
||||
'tours.paddle.wind.mean': 'Mean',
|
||||
'tours.paddle.wind.gusts': 'Gusts',
|
||||
'tours.paddle.wind.fromInfo': 'Wind from {direction}, the direction it blows from most of the day',
|
||||
'tours.paddle.wind.meanInfo': 'The strongest hourly mean wind of the day',
|
||||
'tours.paddle.wind.gustsInfo': 'The strongest gust of the day',
|
||||
'tours.paddle.wind.source': '{source}, {time}, forecast at the start',
|
||||
'tours.paddle.wind.hint.headwind': 'Headwind costs time and strength.',
|
||||
'tours.paddle.wind.hint.drift': 'Crosswind pushes the boat off course.',
|
||||
'tours.paddle.wind.hint.crossing': 'Open crossings are more exposed than the shore.',
|
||||
'tours.paddle.wind.hint.gusts': 'Gusts are stronger than the mean and come suddenly.',
|
||||
'tours.paddle.wind.hint.local': 'No figure is an all-clear. Check the conditions on site.',
|
||||
'tours.paddle.obstacles.loading': 'Loading the obstacles along the route …',
|
||||
'tours.paddle.obstacles.covered':
|
||||
'{count} obstacles and access points along the route, from OpenStreetMap as of {date}. The data can have gaps, so check the water yourself.',
|
||||
'tours.paddle.obstacles.covered.one':
|
||||
'{count} obstacle or access point along the route, from OpenStreetMap as of {date}. The data can have gaps, so check the water yourself.',
|
||||
'tours.paddle.obstacles.coveredEmpty':
|
||||
'No known obstacles along the route (OpenStreetMap as of {date}). Check the water yourself all the same.',
|
||||
'tours.paddle.obstacles.partial':
|
||||
'Obstacle data covers only part of the route (as of {date}). Check the rest yourself.',
|
||||
'tours.paddle.obstacles.none': 'No obstacle data for this area. Check weirs, locks and portages here yourself.',
|
||||
'tours.paddle.obstacles.offline': 'Without a connection TREK loads no obstacle data. Check the water yourself.',
|
||||
'tours.paddle.obstacles.error': 'The obstacle data could not be loaded. Check the water yourself.',
|
||||
'tours.paddle.obstacle.kind.weir': 'Weir',
|
||||
'tours.paddle.obstacle.kind.dam': 'Dam',
|
||||
'tours.paddle.obstacle.kind.lock': 'Lock',
|
||||
'tours.paddle.obstacle.kind.lock_gate': 'Lock gate',
|
||||
'tours.paddle.obstacle.kind.waterfall': 'Waterfall',
|
||||
'tours.paddle.obstacle.kind.rapids': 'Rapids',
|
||||
'tours.paddle.obstacle.kind.hazard': 'Hazard',
|
||||
'tours.paddle.obstacle.kind.put_in': 'Put-in',
|
||||
'tours.paddle.obstacle.kind.egress': 'Take-out',
|
||||
'tours.paddle.obstacle.kind.slipway': 'Slipway',
|
||||
'tours.paddle.obstacle.kind.portage': 'Portage',
|
||||
'tours.paddle.obstacle.kind.access': 'Access rule',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -13,6 +13,10 @@ export * from './common/pagination.schema';
|
||||
|
||||
// Domain contracts
|
||||
export * from './weather/weather.schema';
|
||||
export * from './weather/wind.schema';
|
||||
export * from './paddle/paddle-obstacles.schema';
|
||||
export * from './paddle/paddle-route-relation';
|
||||
export * from './paddle/paddle-regions.schema';
|
||||
export * from './public-api/public-api.schema';
|
||||
export * from './airport/airport.schema';
|
||||
export * from './config/config.schema';
|
||||
@@ -52,6 +56,8 @@ export * from './tours/tours.schema';
|
||||
export * from './tours/tour-provenance.schema';
|
||||
export * from './tours/tours.metrics';
|
||||
export * from './tours/tour-gpx-import.schema';
|
||||
export * from './tours/paddle-plan.schema';
|
||||
export * from './tours/paddle-plan';
|
||||
export * from './wanderer/wanderer.schema';
|
||||
export * from './geo/haversine';
|
||||
export * from './collection/collection.schema';
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* PAD-05A: paddle obstacles and access rules read out of OpenStreetMap, region
|
||||
* by region. Every object keeps its own OSM reference and geometry (a weir
|
||||
* across a river is a line, never only a point), the tags it was judged by
|
||||
* and a hash of them, so a later read tells a changed object from an
|
||||
* unchanged one. The map shows them (PAD-05B), the checklist decides about
|
||||
* them (PAD-06); neither turns a missing object into a free passage.
|
||||
*/
|
||||
|
||||
export const paddleObstacleKindSchema = z.enum([
|
||||
'weir',
|
||||
'dam',
|
||||
'lock',
|
||||
'lock_gate',
|
||||
'waterfall',
|
||||
'rapids',
|
||||
'hazard',
|
||||
'put_in',
|
||||
'egress',
|
||||
'slipway',
|
||||
'portage',
|
||||
'access',
|
||||
]);
|
||||
export type PaddleObstacleKind = z.infer<typeof paddleObstacleKindSchema>;
|
||||
|
||||
/** The kinds a paddler has to decide about; the rest are places and ways that help. */
|
||||
export const PADDLE_BLOCKING_KINDS: readonly PaddleObstacleKind[] = [
|
||||
'weir',
|
||||
'dam',
|
||||
'lock',
|
||||
'lock_gate',
|
||||
'waterfall',
|
||||
'rapids',
|
||||
'hazard',
|
||||
'access',
|
||||
];
|
||||
|
||||
const position = z.tuple([z.number().finite(), z.number().finite()]);
|
||||
/** GeoJSON order, [lng, lat], as OSM tools exchange it. */
|
||||
export const paddleGeometrySchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('Point'), coordinates: position }),
|
||||
z.object({ type: z.literal('LineString'), coordinates: z.array(position).min(2) }),
|
||||
z.object({ type: z.literal('Polygon'), coordinates: z.array(z.array(position).min(4)).min(1) }),
|
||||
]);
|
||||
export type PaddleGeometry = z.infer<typeof paddleGeometrySchema>;
|
||||
|
||||
export const paddleFeatureSchema = z.object({
|
||||
/** `node/123` or `way/456`: the object in OpenStreetMap. */
|
||||
ref: z.string(),
|
||||
kind: paddleObstacleKindSchema,
|
||||
/** The objects of one lock (chamber, gates) share a key; null for an object on its own. */
|
||||
group_key: z.string().nullable(),
|
||||
name: z.string().nullable(),
|
||||
geometry: paddleGeometrySchema,
|
||||
/** The tags it was judged by, as OSM holds them. */
|
||||
tags: z.record(z.string(), z.string()),
|
||||
/** A hash of its tags and geometry: a new value means the object changed. */
|
||||
tag_hash: z.string(),
|
||||
region_id: z.string(),
|
||||
});
|
||||
export type PaddleFeature = z.infer<typeof paddleFeatureSchema>;
|
||||
|
||||
export const paddleCoverageStateSchema = z.enum(['covered', 'partial', 'none']);
|
||||
export type PaddleCoverageState = z.infer<typeof paddleCoverageStateSchema>;
|
||||
|
||||
export const paddleCoverageRegionSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
/** The date the region's data is current to, ISO 8601; null when its file named none. */
|
||||
source_timestamp: z.string().nullable(),
|
||||
/** When TREK last read the region, ISO 8601. */
|
||||
imported_at: z.string().nullable(),
|
||||
});
|
||||
export type PaddleCoverageRegion = z.infer<typeof paddleCoverageRegionSchema>;
|
||||
|
||||
export const paddleObstaclesQuerySchema = z.object({
|
||||
/** south,west,north,east in degrees. */
|
||||
bbox: z.string().regex(/^-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?$/),
|
||||
});
|
||||
export type PaddleObstaclesQuery = z.infer<typeof paddleObstaclesQuerySchema>;
|
||||
|
||||
/** The most objects one answer carries; a wider area answers cut and says so. */
|
||||
export const PADDLE_OBSTACLES_CAP = 2000;
|
||||
/** The widest area one request may ask for, in degrees a side. */
|
||||
export const PADDLE_OBSTACLES_MAX_SPAN_DEG = 1;
|
||||
|
||||
export const paddleObstaclesResponseSchema = z.object({
|
||||
features: z.array(paddleFeatureSchema),
|
||||
/** Whether the area lies inside regions TREK holds data for: wholly, in part, or not at all. */
|
||||
coverage: z.object({
|
||||
state: paddleCoverageStateSchema,
|
||||
regions: z.array(paddleCoverageRegionSchema),
|
||||
}),
|
||||
/** The answer was cut at PADDLE_OBSTACLES_CAP. */
|
||||
truncated: z.boolean(),
|
||||
});
|
||||
export type PaddleObstaclesResponse = z.infer<typeof paddleObstaclesResponseSchema>;
|
||||
|
||||
export const PADDLE_OBSTACLES_ATTRIBUTION = '© OpenStreetMap contributors (ODbL)';
|
||||
|
||||
/** What the access tags say about paddling here, read canoe first, then boat. */
|
||||
export interface PaddleAccess {
|
||||
/** The value the tags give; null when none of them speak to boats. */
|
||||
value: string | null;
|
||||
/** Which tag it came from: the canoe-specific one wins over the general boat one. */
|
||||
source: 'canoe' | 'boat' | 'access' | null;
|
||||
/** A condition the rule carries (season, hours), as written; it is shown, never evaluated. */
|
||||
condition: string | null;
|
||||
/** The value forbids paddling outright, as far as the tags go. */
|
||||
forbidden: boolean;
|
||||
/** Private ground or water: no permission, whatever the line suggests. */
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The access rule for paddlers in an object's tags. `canoe` is read before
|
||||
* `boat`, and `access` last; a conditional rule is kept as written. A value
|
||||
* nobody gave stays null: missing is no permission.
|
||||
*/
|
||||
export function paddleAccessOf(tags: Readonly<Record<string, string>>): PaddleAccess {
|
||||
const pick = (['canoe', 'boat', 'access'] as const).find((key) => tags[key] !== undefined);
|
||||
const conditional = tags['canoe:conditional'] ?? tags['boat:conditional'] ?? null;
|
||||
const value = pick ? (tags[pick] ?? null) : null;
|
||||
return {
|
||||
value,
|
||||
source: pick ?? null,
|
||||
condition: conditional,
|
||||
forbidden: value === 'no',
|
||||
private: value === 'private',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* PAD-05A, the admin side: the regions TREK reads paddle data for. Each is
|
||||
* one of Geofabrik's regional OpenStreetMap extracts, chosen by its id there
|
||||
* (`germany/bayern/oberpfalz`); TREK downloads it, reads the paddle objects
|
||||
* out and keeps nothing else of it. A region is read again every month.
|
||||
*/
|
||||
|
||||
export const paddleRegionStatusSchema = z.enum(['queued', 'importing', 'ready', 'failed']);
|
||||
export type PaddleRegionStatus = z.infer<typeof paddleRegionStatusSchema>;
|
||||
|
||||
export const paddleRegionSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
status: paddleRegionStatusSchema,
|
||||
/** The date the region's data is current to, ISO 8601; null before the first read. */
|
||||
source_timestamp: z.string().nullable(),
|
||||
/** When TREK last read it, ISO 8601. */
|
||||
imported_at: z.string().nullable(),
|
||||
feature_count: z.number().int(),
|
||||
/** Why the last read failed, as plain text; null otherwise. */
|
||||
error: z.string().nullable(),
|
||||
});
|
||||
export type PaddleRegion = z.infer<typeof paddleRegionSchema>;
|
||||
|
||||
export const paddleRegionListResponseSchema = z.object({ regions: z.array(paddleRegionSchema) });
|
||||
export type PaddleRegionListResponse = z.infer<typeof paddleRegionListResponseSchema>;
|
||||
|
||||
export const paddleRegionCatalogEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
/** The regions it lies in, outermost first, for telling "Limburg" from "Limburg". */
|
||||
path: z.array(z.string()),
|
||||
});
|
||||
export type PaddleRegionCatalogEntry = z.infer<typeof paddleRegionCatalogEntrySchema>;
|
||||
|
||||
export const paddleRegionCatalogQuerySchema = z.object({ q: z.string().trim().min(2).max(80) });
|
||||
export const paddleRegionCatalogResponseSchema = z.object({ regions: z.array(paddleRegionCatalogEntrySchema) });
|
||||
export type PaddleRegionCatalogResponse = z.infer<typeof paddleRegionCatalogResponseSchema>;
|
||||
|
||||
/** Geofabrik's region ids: lower case words, digits and dashes. */
|
||||
export const paddleRegionIdSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2)
|
||||
.max(120)
|
||||
.regex(/^[a-z0-9-]+$/);
|
||||
|
||||
export const paddleRegionRequestSchema = z.object({ id: paddleRegionIdSchema });
|
||||
export type PaddleRegionRequest = z.infer<typeof paddleRegionRequestSchema>;
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { PaddleFeature, PaddleGeometry } from './paddle-obstacles.schema';
|
||||
|
||||
/**
|
||||
* PAD-05A/05B: how an obstacle stands to a paddle route. Only a line that
|
||||
* crosses it, or passes within ON_ROUTE_M, makes it an obstacle on the route.
|
||||
* Within NEAR_M it is near, and stays uncertain: a weir on the next arm of a
|
||||
* river is close without being in the way. Farther off it is not related.
|
||||
*/
|
||||
|
||||
/** Closer than this, in metres, the route meets the object. */
|
||||
export const ON_ROUTE_M = 25;
|
||||
/** Closer than this the object is near the route, its relation to it uncertain. */
|
||||
export const NEAR_M = 150;
|
||||
|
||||
export type PaddleRelation = 'on_route' | 'near';
|
||||
|
||||
export interface RelatedObstacle {
|
||||
feature: PaddleFeature;
|
||||
relation: PaddleRelation;
|
||||
/** The shortest distance between the route and the object, in metres. */
|
||||
distanceM: number;
|
||||
/** How far along the route the closest point lies, in metres from its start. */
|
||||
alongM: number;
|
||||
}
|
||||
|
||||
type LatLng = readonly [number, number];
|
||||
|
||||
const METRES_PER_DEG = 111_195;
|
||||
|
||||
interface Local {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** A flat view around a latitude, good to a few metres over the length of a day tour. */
|
||||
function projector(lat0: number): (lat: number, lng: number) => Local {
|
||||
const cos = Math.cos((lat0 * Math.PI) / 180);
|
||||
return (lat, lng) => ({ x: lng * METRES_PER_DEG * cos, y: lat * METRES_PER_DEG });
|
||||
}
|
||||
|
||||
function closestOnSegment(p: Local, a: Local, b: Local): { distance: number; t: number } {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const lengthSq = dx * dx + dy * dy;
|
||||
const t = lengthSq === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSq));
|
||||
return { distance: Math.hypot(p.x - a.x - t * dx, p.y - a.y - t * dy), t };
|
||||
}
|
||||
|
||||
function crosses(a: Local, b: Local, c: Local, d: Local): number | null {
|
||||
const denominator = (b.x - a.x) * (d.y - c.y) - (b.y - a.y) * (d.x - c.x);
|
||||
if (denominator === 0) return null;
|
||||
const t = ((c.x - a.x) * (d.y - c.y) - (c.y - a.y) * (d.x - c.x)) / denominator;
|
||||
const u = ((c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x)) / denominator;
|
||||
return t >= 0 && t <= 1 && u >= 0 && u <= 1 ? t : null;
|
||||
}
|
||||
|
||||
/** The object's outline as segments (a point is one segment of length zero). */
|
||||
function segmentsOf(geometry: PaddleGeometry, project: (lat: number, lng: number) => Local): Array<[Local, Local]> {
|
||||
const line =
|
||||
geometry.type === 'Point'
|
||||
? [geometry.coordinates, geometry.coordinates]
|
||||
: geometry.type === 'LineString'
|
||||
? geometry.coordinates
|
||||
: (geometry.coordinates[0] ?? []);
|
||||
const points = line.map(([lng, lat]) => project(lat, lng));
|
||||
const segments: Array<[Local, Local]> = [];
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const a = points[i - 1];
|
||||
const b = points[i];
|
||||
if (a && b) segments.push([a, b]);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* The obstacles on or near a route, in the order the route reaches them. The
|
||||
* route is `[lat, lng]` pairs as the planner draws it; an empty or one-point
|
||||
* route relates nothing.
|
||||
*/
|
||||
export function relateObstaclesToRoute(
|
||||
route: readonly LatLng[],
|
||||
features: readonly PaddleFeature[],
|
||||
): RelatedObstacle[] {
|
||||
if (route.length < 2) return [];
|
||||
const project = projector(route[0]?.[0] ?? 0);
|
||||
const path = route.map(([lat, lng]) => project(lat, lng));
|
||||
const along: number[] = [0];
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const a = path[i - 1] as Local;
|
||||
const b = path[i] as Local;
|
||||
along.push((along[i - 1] ?? 0) + Math.hypot(b.x - a.x, b.y - a.y));
|
||||
}
|
||||
const related: RelatedObstacle[] = [];
|
||||
for (const feature of features) {
|
||||
let best = { distance: Number.POSITIVE_INFINITY, alongM: 0 };
|
||||
const outline = segmentsOf(feature.geometry, project);
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const a = path[i - 1] as Local;
|
||||
const b = path[i] as Local;
|
||||
const start = along[i - 1] ?? 0;
|
||||
const length = (along[i] ?? start) - start;
|
||||
for (const [c, d] of outline) {
|
||||
const hit = crosses(a, b, c, d);
|
||||
if (hit !== null) {
|
||||
if (best.distance > 0 || start + hit * length < best.alongM) best = { distance: 0, alongM: start + hit * length };
|
||||
continue;
|
||||
}
|
||||
for (const p of [c, d]) {
|
||||
const near = closestOnSegment(p, a, b);
|
||||
if (near.distance < best.distance) best = { distance: near.distance, alongM: start + near.t * length };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best.distance > NEAR_M) continue;
|
||||
related.push({
|
||||
feature,
|
||||
relation: best.distance <= ON_ROUTE_M ? 'on_route' : 'near',
|
||||
distanceM: Math.round(best.distance),
|
||||
alongM: Math.round(best.alongM),
|
||||
});
|
||||
}
|
||||
return related.sort((a, b) => a.alongM - b.alongM);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* PAD-03 and PAD-07: what a paddle tour carries beyond its line. A stretch the
|
||||
* boat is carried (a portage), a lock with the times the paddler expects at it,
|
||||
* and the planning speeds the estimate is reckoned with. These are the
|
||||
* paddler's own plan, never facts from a data source.
|
||||
*/
|
||||
|
||||
/** TREK's planning starting values, to adjust: not norms, not measurements. */
|
||||
export const DEFAULT_PORTAGE_SPEED_KMH = 2;
|
||||
export const DEFAULT_PORTAGE_EXTRA_MINUTES = 10;
|
||||
|
||||
/** The highest carrying speed a paddler can enter, in km/h. */
|
||||
export const MAX_PORTAGE_SPEED_KMH = 10;
|
||||
export const portageSpeedKmhSchema = z.number().finite().min(0.5).max(MAX_PORTAGE_SPEED_KMH);
|
||||
|
||||
/** Minutes one portage or one lock may take in a plan; a day tour stays within a day. */
|
||||
export const MAX_PADDLE_STOP_MINUTES = 12 * 60;
|
||||
export const paddleStopMinutesSchema = z.number().int().min(0).max(MAX_PADDLE_STOP_MINUTES);
|
||||
|
||||
/**
|
||||
* A lock on the tour, with the time the paddler expects to wait for it and to
|
||||
* pass it. Either may be unknown: an unknown time is missing from the
|
||||
* estimate, never zero.
|
||||
*/
|
||||
export const paddleLockSchema = z.object({
|
||||
wait_minutes: paddleStopMinutesSchema.nullable(),
|
||||
transit_minutes: paddleStopMinutesSchema.nullable(),
|
||||
});
|
||||
export type PaddleLock = z.infer<typeof paddleLockSchema>;
|
||||
|
||||
/** The most portages or locks one recorded track may be marked with. */
|
||||
export const MAX_TRACK_MARKS = 50;
|
||||
|
||||
/**
|
||||
* Marks laid onto a recorded track (a GPX import), which is never redrawn: a
|
||||
* portage is the stretch between two distances along it, a lock one distance.
|
||||
* Distances are metres from the start of the track as recorded.
|
||||
*/
|
||||
export const trackPortageMarkSchema = z
|
||||
.object({
|
||||
from_m: z.number().finite().min(0),
|
||||
to_m: z.number().finite().min(0),
|
||||
/** Getting out, unloading and putting back in; null takes the starting value. */
|
||||
extra_minutes: paddleStopMinutesSchema.nullable(),
|
||||
})
|
||||
.refine((mark) => mark.to_m > mark.from_m, { message: 'a portage ends after it begins', path: ['to_m'] });
|
||||
export type TrackPortageMark = z.infer<typeof trackPortageMarkSchema>;
|
||||
|
||||
export const trackLockMarkSchema = paddleLockSchema.extend({
|
||||
at_m: z.number().finite().min(0),
|
||||
});
|
||||
export type TrackLockMark = z.infer<typeof trackLockMarkSchema>;
|
||||
|
||||
export const trackMarksSchema = z
|
||||
.object({
|
||||
portages: z.array(trackPortageMarkSchema).max(MAX_TRACK_MARKS),
|
||||
locks: z.array(trackLockMarkSchema).max(MAX_TRACK_MARKS),
|
||||
})
|
||||
.superRefine((marks, ctx) => {
|
||||
const sorted = [...marks.portages].sort((a, b) => a.from_m - b.from_m);
|
||||
sorted.forEach((mark, i) => {
|
||||
const before = sorted[i - 1];
|
||||
if (before && mark.from_m < before.to_m)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['portages'], message: 'portages must not overlap' });
|
||||
});
|
||||
});
|
||||
export type TrackMarks = z.infer<typeof trackMarksSchema>;
|
||||
|
||||
/**
|
||||
* PUT /api/trips/:tripId/tours/:placeId/paddle-plan: the paddle plan of a tour
|
||||
* that has no control points of its own, a recorded track. A planned tour
|
||||
* saves the same values with its route.
|
||||
*/
|
||||
export const tourPaddlePlanRequestSchema = z.object({
|
||||
paddle_speed_kmh: z.number().finite().min(0.5).max(20).nullable(),
|
||||
portage_speed_kmh: portageSpeedKmhSchema.nullable(),
|
||||
track_marks: trackMarksSchema,
|
||||
});
|
||||
export type TourPaddlePlanRequest = z.infer<typeof tourPaddlePlanRequestSchema>;
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
estimatePaddleTime,
|
||||
paddleLayoutOfPoints,
|
||||
paddleLayoutOfTrack,
|
||||
paddleMovingSeconds,
|
||||
paddleRolesOf,
|
||||
type PaddlePlanPoint,
|
||||
} from './paddle-plan';
|
||||
import { tourPaddlePlanRequestSchema, trackMarksSchema } from './paddle-plan.schema';
|
||||
import { tourCreateRequestSchema } from './tours.schema';
|
||||
|
||||
/** A point `km` kilometres east of 53°N 13°E along the parallel, near enough for these sums. */
|
||||
function east(km: number): { lat: number; lng: number } {
|
||||
return { lat: 53, lng: 13 + km / (111.19493 * Math.cos((53 * Math.PI) / 180)) };
|
||||
}
|
||||
|
||||
function leg(kind: 'free' | 'portage', from: { lat: number; lng: number }, to: { lat: number; lng: number }, extra?: number) {
|
||||
return {
|
||||
kind,
|
||||
geometry: [
|
||||
[from.lat, from.lng],
|
||||
[to.lat, to.lng],
|
||||
] as [number, number][],
|
||||
...(extra === undefined ? {} : { extra_minutes: extra }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('paddle plan (PAD-03, PAD-07)', () => {
|
||||
it('names put-in, take-out, re-entry and final take-out from the legs around each point', () => {
|
||||
const [a, b, c, d, e] = [east(0), east(1), east(1.2), east(1.4), east(3)];
|
||||
const points: PaddlePlanPoint[] = [
|
||||
{ ...a, fixed_leg: leg('free', a, b) },
|
||||
{ ...b, fixed_leg: leg('portage', b, c) },
|
||||
{ ...c, fixed_leg: leg('portage', c, d) },
|
||||
{ ...d, fixed_leg: leg('free', d, e) },
|
||||
{ ...e },
|
||||
];
|
||||
expect(paddleRolesOf(points)).toEqual(['put_in', 'take_out', null, 're_entry', 'final_take_out']);
|
||||
});
|
||||
|
||||
it("reckons the card's example: 8 km water, a 0.4 km portage, a lock gone through and a break make 192 minutes", () => {
|
||||
const [a, b, c, d, e] = [east(0), east(4), east(4.4), east(6), east(8.4)];
|
||||
const points: PaddlePlanPoint[] = [
|
||||
{ ...a, fixed_leg: leg('free', a, b) },
|
||||
{ ...b, fixed_leg: leg('portage', b, c) },
|
||||
{ ...c, fixed_leg: leg('free', c, d) },
|
||||
{ ...d, fixed_leg: leg('free', d, e), lock: { wait_minutes: 20, transit_minutes: 10 } },
|
||||
{ ...e },
|
||||
];
|
||||
const estimate = estimatePaddleTime(paddleLayoutOfPoints(points), {
|
||||
paddleSpeedKmh: 4,
|
||||
portageSpeedKmh: null,
|
||||
breakMinutes: 20,
|
||||
});
|
||||
expect(estimate.waterM).toBeCloseTo(8000, -1);
|
||||
expect(estimate.landM).toBeCloseTo(400, -1);
|
||||
expect(estimate.waterMinutes).toBe(120);
|
||||
expect(estimate.carryMinutes).toBe(12);
|
||||
expect(estimate.portageExtraMinutes).toBe(10);
|
||||
expect(estimate.lockMinutes).toBe(30);
|
||||
expect(estimate.estimateMinutes).toBe(192);
|
||||
expect(estimate.complete).toBe(true);
|
||||
expect(paddleMovingSeconds(estimate)).toBe(172 * 60);
|
||||
});
|
||||
|
||||
it('joins consecutive portage legs into one portage that keeps the time of its first leg', () => {
|
||||
const [a, b, c, d] = [east(0), east(1), east(1.2), east(2)];
|
||||
const layout = paddleLayoutOfPoints([
|
||||
{ ...a, fixed_leg: leg('portage', a, b, 25) },
|
||||
{ ...b, fixed_leg: leg('portage', b, c) },
|
||||
{ ...c, fixed_leg: leg('free', c, d) },
|
||||
{ ...d },
|
||||
]);
|
||||
expect(layout.stretches.map((s) => [s.kind, s.extraMinutes, s.startIndex])).toEqual([
|
||||
['portage', 25, 0],
|
||||
['water', null, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('counts a lock a portage carries round with the portage, and names a lock without times as missing', () => {
|
||||
const [a, b, c, d] = [east(0), east(2), east(2.3), east(4)];
|
||||
const estimate = estimatePaddleTime(
|
||||
paddleLayoutOfPoints([
|
||||
{ ...a, fixed_leg: leg('free', a, b), lock: { wait_minutes: null, transit_minutes: 5 } },
|
||||
{ ...b, fixed_leg: leg('portage', b, c), lock: { wait_minutes: 30, transit_minutes: 10 } },
|
||||
{ ...c, fixed_leg: leg('free', c, d) },
|
||||
{ ...d },
|
||||
]),
|
||||
{ paddleSpeedKmh: 4, portageSpeedKmh: 2, breakMinutes: null },
|
||||
);
|
||||
expect(estimate.locksCounted).toBe(1);
|
||||
expect(estimate.lockMinutes).toBe(5);
|
||||
expect(estimate.locksMissingTime).toBe(1);
|
||||
expect(estimate.complete).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the water time unknown without a paddling speed, and the estimate incomplete', () => {
|
||||
const [a, b] = [east(0), east(2)];
|
||||
const estimate = estimatePaddleTime(paddleLayoutOfPoints([{ ...a, fixed_leg: leg('free', a, b) }, { ...b }]), {
|
||||
paddleSpeedKmh: null,
|
||||
portageSpeedKmh: null,
|
||||
breakMinutes: null,
|
||||
});
|
||||
expect(estimate.waterMinutes).toBeNull();
|
||||
expect(estimate.complete).toBe(false);
|
||||
expect(paddleMovingSeconds(estimate)).toBeNull();
|
||||
});
|
||||
|
||||
it('reads a recorded track from its marks: water around each portage, a lock inside one bypassed', () => {
|
||||
const layout = paddleLayoutOfTrack(5000, {
|
||||
portages: [{ from_m: 1000, to_m: 1300, extra_minutes: null }],
|
||||
locks: [
|
||||
{ at_m: 1200, wait_minutes: 10, transit_minutes: 10 },
|
||||
{ at_m: 4000, wait_minutes: 15, transit_minutes: 5 },
|
||||
],
|
||||
});
|
||||
expect(layout.stretches.map((s) => [s.kind, s.fromM, s.toM])).toEqual([
|
||||
['water', 0, 1000],
|
||||
['portage', 1000, 1300],
|
||||
['water', 1300, 5000],
|
||||
]);
|
||||
expect(layout.locks.map((lock) => lock.bypassed)).toEqual([true, false]);
|
||||
const estimate = estimatePaddleTime(layout, { paddleSpeedKmh: 4, portageSpeedKmh: null, breakMinutes: 0 });
|
||||
expect(estimate.lockMinutes).toBe(20);
|
||||
expect(estimate.portageCount).toBe(1);
|
||||
});
|
||||
|
||||
it('refuses overlapping or reversed portages on a track', () => {
|
||||
expect(
|
||||
trackMarksSchema.safeParse({
|
||||
portages: [
|
||||
{ from_m: 0, to_m: 500, extra_minutes: null },
|
||||
{ from_m: 400, to_m: 900, extra_minutes: null },
|
||||
],
|
||||
locks: [],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
tourPaddlePlanRequestSchema.safeParse({
|
||||
paddle_speed_kmh: null,
|
||||
portage_speed_kmh: null,
|
||||
track_marks: { portages: [{ from_m: 600, to_m: 500, extra_minutes: null }], locks: [] },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps portages, locks and a carrying speed to paddle tours', () => {
|
||||
const route = [
|
||||
[53, 13, 0],
|
||||
[53, 13.01, 0],
|
||||
] as [number, number, number][];
|
||||
const base = {
|
||||
name: 'Carry',
|
||||
route_geometry: route,
|
||||
waypoints: [
|
||||
{ lat: 53, lng: 13, role: 'start', sequence: 0, fixed_leg: leg('portage', { lat: 53, lng: 13 }, { lat: 53, lng: 13.01 }) },
|
||||
{ lat: 53, lng: 13.01, role: 'end', sequence: 1, lock: { wait_minutes: 5, transit_minutes: 5 } },
|
||||
],
|
||||
};
|
||||
const hike = tourCreateRequestSchema.safeParse({ ...base, tour_type: 'hike', portage_speed_kmh: 2 });
|
||||
expect(hike.success).toBe(false);
|
||||
expect(hike.error?.issues.map((issue) => issue.path.join('.'))).toEqual(
|
||||
expect.arrayContaining(['portage_speed_kmh', 'waypoints.0.fixed_leg', 'waypoints.1.lock']),
|
||||
);
|
||||
const paddle = tourCreateRequestSchema.safeParse({
|
||||
...base,
|
||||
tour_type: 'kayak',
|
||||
boat_type: 'canoe',
|
||||
water_context: 'river',
|
||||
portage_speed_kmh: 2.5,
|
||||
});
|
||||
expect(paddle.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { haversineMetres } from '../geo/haversine';
|
||||
import {
|
||||
DEFAULT_PORTAGE_EXTRA_MINUTES,
|
||||
DEFAULT_PORTAGE_SPEED_KMH,
|
||||
type PaddleLock,
|
||||
type TrackMarks,
|
||||
} from './paddle-plan.schema';
|
||||
|
||||
/**
|
||||
* PAD-03 and PAD-07 as plain arithmetic, so the planner, the detail view and
|
||||
* the server read one answer: which stretches of a paddle tour are on water
|
||||
* and which are carried, where its locks are, what each part takes and what
|
||||
* of the estimate is still missing.
|
||||
*/
|
||||
|
||||
/** What a point of a paddle tour is to the paddler, read from the legs on either side of it. */
|
||||
export type PaddleRole = 'put_in' | 'take_out' | 're_entry' | 'final_take_out';
|
||||
|
||||
interface PlanLeg {
|
||||
kind: string;
|
||||
/** Read as plain values: a workspace compiled without strict mode sees the schema's tuples as "maybe two". */
|
||||
geometry: ReadonlyArray<ReadonlyArray<unknown>>;
|
||||
extra_minutes?: number | null;
|
||||
}
|
||||
|
||||
/** A control point as far as the paddle plan reads it. */
|
||||
export interface PaddlePlanPoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
fixed_leg?: PlanLeg | null;
|
||||
lock?: PaddleLock | null;
|
||||
}
|
||||
|
||||
export interface PaddleStretch {
|
||||
kind: 'water' | 'portage';
|
||||
fromM: number;
|
||||
toM: number;
|
||||
/** A portage's time for getting out, unloading and back in; null means the starting value. */
|
||||
extraMinutes: number | null;
|
||||
/** The control point the stretch starts at, for a planned tour. */
|
||||
startIndex: number | null;
|
||||
}
|
||||
|
||||
export interface PaddleLockStop extends PaddleLock {
|
||||
atM: number;
|
||||
/** A planned portage carries round it: its times count instead of the lock's. */
|
||||
bypassed: boolean;
|
||||
/** The control point it sits at, for a planned tour. */
|
||||
index: number | null;
|
||||
}
|
||||
|
||||
export interface PaddleLayout {
|
||||
totalM: number;
|
||||
stretches: PaddleStretch[];
|
||||
locks: PaddleLockStop[];
|
||||
}
|
||||
|
||||
function isPortage(leg: PlanLeg | null | undefined): boolean {
|
||||
return leg?.kind === 'portage';
|
||||
}
|
||||
|
||||
function lineLengthM(line: PlanLeg['geometry']): number {
|
||||
let total = 0;
|
||||
for (let i = 1; i < line.length; i++) {
|
||||
const a = line[i - 1];
|
||||
const b = line[i];
|
||||
if (a && b) total += haversineMetres(Number(a[0]), Number(a[1]), Number(b[0]), Number(b[1]));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* The role of every control point: the first puts in, the last takes out for
|
||||
* good, a point where water gives way to a portage is a take-out and where a
|
||||
* portage meets water again a re-entry. Points within a stretch have none.
|
||||
*/
|
||||
export function paddleRolesOf(points: readonly PaddlePlanPoint[]): Array<PaddleRole | null> {
|
||||
return points.map((point, index) => {
|
||||
if (index === 0) return 'put_in';
|
||||
if (index === points.length - 1) return 'final_take_out';
|
||||
const carriedIn = isPortage(points[index - 1]?.fixed_leg);
|
||||
const carriedOut = isPortage(point.fixed_leg);
|
||||
if (!carriedIn && carriedOut) return 'take_out';
|
||||
if (carriedIn && !carriedOut) return 're_entry';
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function bypassedBy(stretches: readonly PaddleStretch[], atM: number): boolean {
|
||||
return stretches.some((s) => s.kind === 'portage' && atM >= s.fromM - 0.5 && atM <= s.toM + 0.5);
|
||||
}
|
||||
|
||||
/** The stretches and locks of a planned tour, measured along the legs between its control points. */
|
||||
export function paddleLayoutOfPoints(points: readonly PaddlePlanPoint[]): PaddleLayout {
|
||||
const stretches: PaddleStretch[] = [];
|
||||
const at: number[] = [];
|
||||
let distance = 0;
|
||||
points.forEach((point, index) => {
|
||||
at.push(distance);
|
||||
const next = points[index + 1];
|
||||
if (!next) return;
|
||||
const leg = point.fixed_leg;
|
||||
const length = leg ? lineLengthM(leg.geometry) : haversineMetres(point.lat, point.lng, next.lat, next.lng);
|
||||
const kind = isPortage(leg) ? 'portage' : 'water';
|
||||
const last = stretches[stretches.length - 1];
|
||||
if (last && last.kind === kind) last.toM = distance + length;
|
||||
else
|
||||
stretches.push({
|
||||
kind,
|
||||
fromM: distance,
|
||||
toM: distance + length,
|
||||
extraMinutes: kind === 'portage' ? (leg?.extra_minutes ?? null) : null,
|
||||
startIndex: index,
|
||||
});
|
||||
distance += length;
|
||||
});
|
||||
const locks = points.flatMap((point, index): PaddleLockStop[] => {
|
||||
if (!point.lock) return [];
|
||||
const atM = at[index] ?? 0;
|
||||
return [{ ...point.lock, atM, bypassed: bypassedBy(stretches, atM), index }];
|
||||
});
|
||||
return { totalM: distance, stretches, locks };
|
||||
}
|
||||
|
||||
/** The stretches and locks of a recorded track, from the marks laid onto it. */
|
||||
export function paddleLayoutOfTrack(totalM: number, marks: TrackMarks | null | undefined): PaddleLayout {
|
||||
const portages = [...(marks?.portages ?? [])]
|
||||
.map((mark) => ({ ...mark, from_m: Math.min(mark.from_m, totalM), to_m: Math.min(mark.to_m, totalM) }))
|
||||
.filter((mark) => mark.to_m > mark.from_m)
|
||||
.sort((a, b) => a.from_m - b.from_m);
|
||||
const stretches: PaddleStretch[] = [];
|
||||
let cursor = 0;
|
||||
for (const mark of portages) {
|
||||
if (mark.from_m > cursor)
|
||||
stretches.push({ kind: 'water', fromM: cursor, toM: mark.from_m, extraMinutes: null, startIndex: null });
|
||||
stretches.push({
|
||||
kind: 'portage',
|
||||
fromM: mark.from_m,
|
||||
toM: mark.to_m,
|
||||
extraMinutes: mark.extra_minutes,
|
||||
startIndex: null,
|
||||
});
|
||||
cursor = mark.to_m;
|
||||
}
|
||||
if (totalM > cursor) stretches.push({ kind: 'water', fromM: cursor, toM: totalM, extraMinutes: null, startIndex: null });
|
||||
const locks = (marks?.locks ?? [])
|
||||
.filter((lock) => lock.at_m <= totalM)
|
||||
.map((lock) => ({
|
||||
wait_minutes: lock.wait_minutes,
|
||||
transit_minutes: lock.transit_minutes,
|
||||
atM: lock.at_m,
|
||||
bypassed: bypassedBy(stretches, lock.at_m),
|
||||
index: null,
|
||||
}))
|
||||
.sort((a, b) => a.atM - b.atM);
|
||||
return { totalM, stretches, locks };
|
||||
}
|
||||
|
||||
export interface PaddleTimeEstimate {
|
||||
waterM: number;
|
||||
landM: number;
|
||||
portageCount: number;
|
||||
/** The speeds the estimate used; the paddling one is null while the paddler has entered none. */
|
||||
paddleSpeedKmh: number | null;
|
||||
portageSpeedKmh: number;
|
||||
/** Null while there is no paddling speed: unknown, never zero. */
|
||||
waterMinutes: number | null;
|
||||
carryMinutes: number;
|
||||
portageExtraMinutes: number;
|
||||
/** The known lock times of the locks the boat goes through. */
|
||||
lockMinutes: number;
|
||||
locksCounted: number;
|
||||
/** Locks gone through whose wait or transit time is not entered. */
|
||||
locksMissingTime: number;
|
||||
breakMinutes: number;
|
||||
/** Every known part added up; with `complete` false it leaves something out. */
|
||||
estimateMinutes: number;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The estimate of PAD-07: water at the paddling speed, each portage carried at
|
||||
* the carrying speed plus its time for getting out and back in, each lock gone
|
||||
* through at the times entered for it, and the breaks. A lock a portage carries
|
||||
* round counts with the portage, not again. What is not known is left out and
|
||||
* named, so the sum is never passed off as complete.
|
||||
*/
|
||||
export function estimatePaddleTime(
|
||||
layout: PaddleLayout,
|
||||
speeds: { paddleSpeedKmh: number | null; portageSpeedKmh: number | null; breakMinutes: number | null },
|
||||
): PaddleTimeEstimate {
|
||||
const portageSpeedKmh = speeds.portageSpeedKmh ?? DEFAULT_PORTAGE_SPEED_KMH;
|
||||
let waterM = 0;
|
||||
let landM = 0;
|
||||
let portageExtraMinutes = 0;
|
||||
let portageCount = 0;
|
||||
for (const stretch of layout.stretches) {
|
||||
const length = stretch.toM - stretch.fromM;
|
||||
if (stretch.kind === 'water') {
|
||||
waterM += length;
|
||||
continue;
|
||||
}
|
||||
landM += length;
|
||||
portageCount += 1;
|
||||
portageExtraMinutes += stretch.extraMinutes ?? DEFAULT_PORTAGE_EXTRA_MINUTES;
|
||||
}
|
||||
const waterMinutes = speeds.paddleSpeedKmh ? (waterM / 1000 / speeds.paddleSpeedKmh) * 60 : null;
|
||||
const carryMinutes = (landM / 1000 / portageSpeedKmh) * 60;
|
||||
let lockMinutes = 0;
|
||||
let locksCounted = 0;
|
||||
let locksMissingTime = 0;
|
||||
for (const lock of layout.locks) {
|
||||
if (lock.bypassed) continue;
|
||||
locksCounted += 1;
|
||||
lockMinutes += (lock.wait_minutes ?? 0) + (lock.transit_minutes ?? 0);
|
||||
if (lock.wait_minutes === null || lock.transit_minutes === null) locksMissingTime += 1;
|
||||
}
|
||||
const breakMinutes = speeds.breakMinutes ?? 0;
|
||||
const estimateMinutes = Math.round(
|
||||
(waterMinutes ?? 0) + carryMinutes + portageExtraMinutes + lockMinutes + breakMinutes,
|
||||
);
|
||||
return {
|
||||
waterM,
|
||||
landM,
|
||||
portageCount,
|
||||
paddleSpeedKmh: speeds.paddleSpeedKmh,
|
||||
portageSpeedKmh,
|
||||
waterMinutes: waterMinutes === null ? null : Math.round(waterMinutes),
|
||||
carryMinutes: Math.round(carryMinutes),
|
||||
portageExtraMinutes,
|
||||
lockMinutes,
|
||||
locksCounted,
|
||||
locksMissingTime,
|
||||
breakMinutes,
|
||||
estimateMinutes,
|
||||
complete: waterMinutes !== null && locksMissingTime === 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** The moving part of the estimate (no breaks), in seconds, as a tour's duration is stored. */
|
||||
export function paddleMovingSeconds(estimate: PaddleTimeEstimate): number | null {
|
||||
if (estimate.waterMinutes === null) return null;
|
||||
return Math.round((estimate.estimateMinutes - estimate.breakMinutes) * 60);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { paddleLockSchema, paddleStopMinutesSchema, portageSpeedKmhSchema, trackMarksSchema } from './paddle-plan.schema';
|
||||
import { tourOriginSchema, tourProvenanceSchema } from './tour-provenance.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
@@ -114,6 +115,10 @@ export const tourSchema = z.object({
|
||||
water_context: paddleWaterContextSchema.nullable().optional(),
|
||||
/** The speed the paddler chose; null while the boat's suggested speed applies. */
|
||||
paddle_speed_kmh: z.number().nullable().optional(),
|
||||
/** PAD-07: the carrying speed of a paddle tour's portages; null while the starting value applies. */
|
||||
portage_speed_kmh: z.number().nullable().optional(),
|
||||
/** PAD-03: portages and locks laid onto a recorded track; null for a planned tour and a track without any. */
|
||||
track_marks: trackMarksSchema.nullable().optional(),
|
||||
});
|
||||
export type Tour = z.infer<typeof tourSchema>;
|
||||
|
||||
@@ -170,7 +175,9 @@ const fixedLegGeometrySchema = z
|
||||
* own line from OpenStreetMap, since a router walks around a ferrata wherever
|
||||
* it can. A free leg (P2A-04) is drawn by hand, straight from one waypoint to
|
||||
* the next: off the path network, across a lake, wherever no router goes.
|
||||
* Nothing about a free leg was checked by a router.
|
||||
* Nothing about a free leg was checked by a router. A portage (PAD-03) is a
|
||||
* free leg on land: the boat is carried along it, so it counts as land and
|
||||
* not as water, with the time getting out and back in takes.
|
||||
*/
|
||||
export const tourFixedLegSchema = z.discriminatedUnion('kind', [
|
||||
z.object({
|
||||
@@ -184,6 +191,12 @@ export const tourFixedLegSchema = z.discriminatedUnion('kind', [
|
||||
kind: z.literal('free'),
|
||||
geometry: fixedLegGeometrySchema,
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('portage'),
|
||||
geometry: fixedLegGeometrySchema,
|
||||
/** Getting out, unloading and back in, on the first leg of a portage; null takes the starting value. */
|
||||
extra_minutes: paddleStopMinutesSchema.nullable().optional(),
|
||||
}),
|
||||
]);
|
||||
export type TourFixedLeg = z.infer<typeof tourFixedLegSchema>;
|
||||
export type TourFixedLegKind = TourFixedLeg['kind'];
|
||||
@@ -195,6 +208,8 @@ export const tourWaypointSchema = z.object({
|
||||
sequence: z.number().int().nonnegative(),
|
||||
/** The leg to the next waypoint as a fixed line; absent or null for a routed one. */
|
||||
fixed_leg: tourFixedLegSchema.nullable().optional(),
|
||||
/** PAD-07: a lock at this point of a paddle tour, with the times the paddler expects there. */
|
||||
lock: paddleLockSchema.nullable().optional(),
|
||||
});
|
||||
export type TourWaypoint = z.infer<typeof tourWaypointSchema>;
|
||||
|
||||
@@ -228,6 +243,7 @@ export const tourCreateRequestSchema = z
|
||||
boat_type: paddleBoatTypeSchema.nullable().optional(),
|
||||
water_context: paddleWaterContextSchema.nullable().optional(),
|
||||
paddle_speed_kmh: paddleSpeedKmhSchema.nullable().optional(),
|
||||
portage_speed_kmh: portageSpeedKmhSchema.nullable().optional(),
|
||||
duration_seconds: z.number().finite().nonnegative().nullable().optional(),
|
||||
planned_duration_minutes: plannedTourDurationMinutesSchema.optional(),
|
||||
break_additional_minutes: plannedTourDurationMinutesSchema.optional(),
|
||||
@@ -303,8 +319,10 @@ function tourTypeIssues(value: {
|
||||
boat_type?: PaddleBoatType | null;
|
||||
water_context?: PaddleWaterContext | null;
|
||||
paddle_speed_kmh?: number | null;
|
||||
}): Array<{ path: string[]; message: string }> {
|
||||
const issues: Array<{ path: string[]; message: string }> = [];
|
||||
portage_speed_kmh?: number | null;
|
||||
waypoints?: ReadonlyArray<{ fixed_leg?: { kind: string } | null; lock?: unknown }>;
|
||||
}): Array<{ path: Array<string | number>; message: string }> {
|
||||
const issues: Array<{ path: Array<string | number>; message: string }> = [];
|
||||
if (value.tour_type === 'kayak') {
|
||||
if (value.boat_type == null) issues.push({ path: ['boat_type'], message: 'a paddle tour needs a boat type' });
|
||||
if (value.water_context == null)
|
||||
@@ -315,9 +333,15 @@ function tourTypeIssues(value: {
|
||||
}
|
||||
if (value.max_hiking_difficulty === null)
|
||||
issues.push({ path: ['max_hiking_difficulty'], message: 'a hike needs a hiking difficulty' });
|
||||
for (const field of ['boat_type', 'water_context', 'paddle_speed_kmh'] as const) {
|
||||
for (const field of ['boat_type', 'water_context', 'paddle_speed_kmh', 'portage_speed_kmh'] as const) {
|
||||
if (value[field] != null) issues.push({ path: [field], message: `${field} belongs to a paddle tour` });
|
||||
}
|
||||
// A portage and a lock are a paddler's: a hike carries no boat.
|
||||
(value.waypoints ?? []).forEach((point, index) => {
|
||||
if (point.fixed_leg?.kind === 'portage')
|
||||
issues.push({ path: ['waypoints', index, 'fixed_leg'], message: 'a portage belongs to a paddle tour' });
|
||||
if (point.lock != null) issues.push({ path: ['waypoints', index, 'lock'], message: 'a lock belongs to a paddle tour' });
|
||||
});
|
||||
return issues;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* PAD-08: the wind forecast for one place and one day, for a paddle tour on
|
||||
* that day. It only ever carries a forecast: a day already gone or beyond the
|
||||
* forecast's reach says so and carries no figures, so a climate value or last
|
||||
* year's wind can never be read as this day's. Speeds are km/h, directions
|
||||
* degrees the wind comes from (meteorological), gusts the strongest within the
|
||||
* hour. A value the source did not give is null, never zero.
|
||||
*/
|
||||
|
||||
/** How many days ahead the forecast reaches, today included. */
|
||||
export const WIND_FORECAST_DAYS = 16;
|
||||
|
||||
export const windQuerySchema = z.object({
|
||||
lat: z.coerce.number().finite().min(-90).max(90),
|
||||
lng: z.coerce.number().finite().min(-180).max(180),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
});
|
||||
export type WindQuery = z.infer<typeof windQuerySchema>;
|
||||
|
||||
export const windHourSchema = z.object({
|
||||
/** Local hour of the place, 0 to 23. */
|
||||
hour: z.number().int().min(0).max(23),
|
||||
speed_kmh: z.number().nullable(),
|
||||
gusts_kmh: z.number().nullable(),
|
||||
direction_deg: z.number().nullable(),
|
||||
});
|
||||
export type WindHour = z.infer<typeof windHourSchema>;
|
||||
|
||||
export const windDaySchema = z.object({
|
||||
/** The strongest hourly mean of the day: a maximum, not an average. */
|
||||
speed_max_kmh: z.number().nullable(),
|
||||
gusts_max_kmh: z.number().nullable(),
|
||||
/** Where the wind came from most over the day. */
|
||||
direction_dominant_deg: z.number().nullable(),
|
||||
});
|
||||
export type WindDay = z.infer<typeof windDaySchema>;
|
||||
|
||||
export const windForecastStatusSchema = z.enum(['forecast', 'past', 'beyond_horizon']);
|
||||
export type WindForecastStatus = z.infer<typeof windForecastStatusSchema>;
|
||||
|
||||
export const windForecastResponseSchema = z.object({
|
||||
status: windForecastStatusSchema,
|
||||
date: z.string(),
|
||||
lat: z.number(),
|
||||
lng: z.number(),
|
||||
/** The place's time zone the hours are in; null when there is no forecast. */
|
||||
timezone: z.string().nullable(),
|
||||
source: z.literal('Open-Meteo'),
|
||||
/** When TREK asked the source for these figures, ISO 8601. */
|
||||
fetched_at: z.string(),
|
||||
horizon_days: z.number().int(),
|
||||
hours: z.array(windHourSchema),
|
||||
day: windDaySchema.nullable(),
|
||||
});
|
||||
export type WindForecastResponse = z.infer<typeof windForecastResponseSchema>;
|
||||
|
||||
/** The 16 compass points a direction is named by, from north clockwise. */
|
||||
export const COMPASS_POINTS = [
|
||||
'N',
|
||||
'NNE',
|
||||
'NE',
|
||||
'ENE',
|
||||
'E',
|
||||
'ESE',
|
||||
'SE',
|
||||
'SSE',
|
||||
'S',
|
||||
'SSW',
|
||||
'SW',
|
||||
'WSW',
|
||||
'W',
|
||||
'WNW',
|
||||
'NW',
|
||||
'NNW',
|
||||
] as const;
|
||||
export type CompassPoint = (typeof COMPASS_POINTS)[number];
|
||||
|
||||
/** The compass point a direction in degrees falls on. */
|
||||
export function compassPointOf(degrees: number): CompassPoint {
|
||||
const index = Math.round((((degrees % 360) + 360) % 360) / 22.5) % 16;
|
||||
return COMPASS_POINTS[index] ?? 'N';
|
||||
}
|
||||
Reference in New Issue
Block a user