Followup to #447. The audit ran on \`as unknown as\` first; this PR closes out \`as any\` separately. After this, \`grep -rn ' as any\\b' apps/ packages/\` returns 0 (excluding tests and node_modules). ## Sites fixed **\`apps/journal/server.ts\`** + **\`packages/jobs/src/types.ts\`** — \`komootBulkImportJob as any\`. The job had a typed payload (\`JobDefinition<KomootBulkImportData>\`) but the worker's \`JobDefinition[]\` array forced a contravariance cast at every site that mixed typed and untyped jobs. Dropped the generic from \`JobDefinition\` entirely; handlers narrow their own \`job.data\`. Only one job (komoot-bulk-import) used the generic, so the surface is tiny. **\`apps/journal/app/lib/connected-services/fit.ts\`** + same pattern in \`routes/sync.import.\$provider.server.ts\` — \`parser.parse(buffer as any, (err: unknown, d: any) => ...)\`. fit-file-parser's TypeScript types require \`Buffer<ArrayBuffer>\` specifically; a generic Node \`Buffer\` is structurally \`Buffer<ArrayBufferLike>\` (which includes SharedArrayBuffer). The runtime accepts either, so narrowed the cast to \`as Buffer<ArrayBuffer>\` — still a cast, but precise about what we're asserting and why. Removed the \`(err, d: any) => …\` ad-hoc callback typings; the library exports a proper \`FitParserCallback\`. **\`apps/mobile/lib/editor/RouteMap.tsx\`** — \`onLongPress\` handler took \`(event: any)\`. maplibre-react-native v11 generates the event type via React Native codegen but doesn't re-export it as a public TypeScript type. Replaced with a local structural slice of the parts we actually read. Net: 4 \`as any\` sites in prod code → 0. \`pnpm typecheck\` / \`lint\` / \`test\` all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
264 lines
6.4 KiB
TypeScript
264 lines
6.4 KiB
TypeScript
import { useRef, useCallback } from "react";
|
|
import { StyleSheet, View, Text } from "react-native";
|
|
import {
|
|
Camera,
|
|
GeoJSONSource,
|
|
Layer,
|
|
Map,
|
|
ViewAnnotation,
|
|
type CameraRef,
|
|
} from "@maplibre/maplibre-react-native";
|
|
import type { Waypoint } from "@trails-cool/types";
|
|
import type { RouteSegment } from "./use-route-editor";
|
|
|
|
const OSM_STYLE = {
|
|
version: 8 as const,
|
|
sources: {
|
|
osm: {
|
|
type: "raster" as const,
|
|
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
|
|
tileSize: 256,
|
|
maxzoom: 19,
|
|
},
|
|
},
|
|
layers: [
|
|
{
|
|
id: "osm",
|
|
type: "raster" as const,
|
|
source: "osm",
|
|
},
|
|
],
|
|
};
|
|
|
|
interface RouteMapProps {
|
|
waypoints: Waypoint[];
|
|
segments: RouteSegment[];
|
|
computing: boolean;
|
|
onLongPress: (lat: number, lon: number) => void;
|
|
onWaypointDragEnd: (index: number, lat: number, lon: number) => void;
|
|
onWaypointPress: (index: number) => void;
|
|
}
|
|
|
|
export function RouteMap({
|
|
waypoints,
|
|
segments,
|
|
computing,
|
|
onLongPress,
|
|
onWaypointDragEnd: _onWaypointDragEnd,
|
|
onWaypointPress,
|
|
}: RouteMapProps) {
|
|
// `Map` is undefined when the native module isn't linked (e.g. running
|
|
// in Expo Go). Same safety net as before the v11 upgrade, just against
|
|
// a named import instead of the removed default export.
|
|
if (!Map) {
|
|
return (
|
|
<View style={styles.fallback}>
|
|
<Text style={styles.fallbackText}>Map</Text>
|
|
<Text style={styles.fallbackHint}>Requires a dev build with MapLibre</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return <RouteMapInner
|
|
waypoints={waypoints} segments={segments} computing={computing}
|
|
onLongPress={onLongPress} onWaypointDragEnd={_onWaypointDragEnd}
|
|
onWaypointPress={onWaypointPress}
|
|
/>;
|
|
}
|
|
|
|
function RouteMapInner({
|
|
waypoints,
|
|
segments,
|
|
computing,
|
|
onLongPress,
|
|
onWaypointDragEnd: _onWaypointDragEnd,
|
|
onWaypointPress,
|
|
}: RouteMapProps) {
|
|
const cameraRef = useRef<CameraRef>(null);
|
|
|
|
const handleLongPress = useCallback(
|
|
// maplibre-react-native v11+ codegen `NativePressEvent` isn't
|
|
// re-exported as a type, so we declare the slice we use locally.
|
|
// Payload moved from `event.geometry.coordinates` (v10) to
|
|
// `event.nativeEvent.lngLat` in v11.
|
|
(event: { nativeEvent?: { lngLat?: readonly [number, number] } }) => {
|
|
const lngLat = event?.nativeEvent?.lngLat;
|
|
if (Array.isArray(lngLat) && lngLat.length >= 2) {
|
|
onLongPress(lngLat[1] as number, lngLat[0] as number);
|
|
}
|
|
},
|
|
[onLongPress],
|
|
);
|
|
|
|
// Build route GeoJSON from segments
|
|
const routeGeojson = {
|
|
type: "FeatureCollection" as const,
|
|
features: segments.map((seg) => ({
|
|
type: "Feature" as const,
|
|
properties: {},
|
|
geometry: {
|
|
type: "LineString" as const,
|
|
coordinates: seg.coordinates,
|
|
},
|
|
})),
|
|
};
|
|
|
|
// Compute bounds for initial camera
|
|
const bounds = computeBounds(waypoints, segments);
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<Map
|
|
style={styles.map}
|
|
mapStyle={OSM_STYLE}
|
|
onLongPress={handleLongPress}
|
|
attribution={false}
|
|
logo={false}
|
|
>
|
|
{bounds && (
|
|
<Camera
|
|
ref={cameraRef}
|
|
initialViewState={{
|
|
bounds: [bounds.sw[0], bounds.sw[1], bounds.ne[0], bounds.ne[1]],
|
|
padding: { top: 40, right: 40, bottom: 40, left: 40 },
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{!bounds && (
|
|
<Camera
|
|
initialViewState={{
|
|
center: [10.0, 50.1],
|
|
zoom: 6,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Route line */}
|
|
<GeoJSONSource id="route" data={routeGeojson}>
|
|
<Layer
|
|
id="route-line"
|
|
type="line"
|
|
paint={{
|
|
"line-color": "#2563eb",
|
|
"line-width": 4,
|
|
"line-opacity": computing ? 0.4 : 1,
|
|
}}
|
|
/>
|
|
</GeoJSONSource>
|
|
|
|
{/* Waypoint markers */}
|
|
{waypoints.map((wp, i) => (
|
|
<ViewAnnotation
|
|
key={`wp-${i}`}
|
|
lngLat={[wp.lon, wp.lat]}
|
|
>
|
|
<WaypointMarker
|
|
index={i}
|
|
isDayBreak={wp.isDayBreak}
|
|
onPress={() => onWaypointPress(i)}
|
|
/>
|
|
</ViewAnnotation>
|
|
))}
|
|
</Map>
|
|
|
|
{computing && (
|
|
<View style={styles.computingBanner}>
|
|
<Text style={styles.computingText}>Computing route...</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function WaypointMarker({
|
|
index,
|
|
isDayBreak,
|
|
onPress,
|
|
}: {
|
|
index: number;
|
|
isDayBreak?: boolean;
|
|
onPress: () => void;
|
|
}) {
|
|
return (
|
|
<View
|
|
style={[styles.marker, isDayBreak && styles.markerOvernight]}
|
|
onTouchEnd={onPress}
|
|
>
|
|
<Text style={styles.markerText}>{index + 1}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function computeBounds(
|
|
waypoints: Waypoint[],
|
|
segments: RouteSegment[],
|
|
): { ne: [number, number]; sw: [number, number] } | null {
|
|
const points: [number, number][] = [
|
|
...waypoints.map((w) => [w.lon, w.lat] as [number, number]),
|
|
...segments.flatMap((s) => s.coordinates),
|
|
];
|
|
|
|
if (points.length === 0) return null;
|
|
|
|
let minLon = Infinity, maxLon = -Infinity;
|
|
let minLat = Infinity, maxLat = -Infinity;
|
|
|
|
for (const [lon, lat] of points) {
|
|
if (lon < minLon) minLon = lon;
|
|
if (lon > maxLon) maxLon = lon;
|
|
if (lat < minLat) minLat = lat;
|
|
if (lat > maxLat) maxLat = lat;
|
|
}
|
|
|
|
return {
|
|
ne: [maxLon, maxLat],
|
|
sw: [minLon, minLat],
|
|
};
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
fallback: {
|
|
flex: 1,
|
|
backgroundColor: "#f3f4f6",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
},
|
|
fallbackText: { fontSize: 16, color: "#9ca3af", fontWeight: "600" },
|
|
fallbackHint: { fontSize: 12, color: "#9ca3af", marginTop: 4 },
|
|
container: { flex: 1 },
|
|
map: { flex: 1 },
|
|
computingBanner: {
|
|
position: "absolute",
|
|
top: 8,
|
|
alignSelf: "center",
|
|
backgroundColor: "rgba(0,0,0,0.7)",
|
|
borderRadius: 16,
|
|
paddingVertical: 6,
|
|
paddingHorizontal: 14,
|
|
},
|
|
computingText: { color: "#fff", fontSize: 13 },
|
|
marker: {
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: 14,
|
|
backgroundColor: "#4A6B40",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
borderWidth: 2,
|
|
borderColor: "#fff",
|
|
shadowColor: "#000",
|
|
shadowOffset: { width: 0, height: 1 },
|
|
shadowOpacity: 0.3,
|
|
shadowRadius: 2,
|
|
elevation: 3,
|
|
},
|
|
markerOvernight: {
|
|
backgroundColor: "#f97316",
|
|
},
|
|
markerText: {
|
|
color: "#fff",
|
|
fontSize: 12,
|
|
fontWeight: "700",
|
|
},
|
|
});
|