import type { GeoPoint, FittedRoute, RouteInput, IntersectionPoint, ConicArc } from "../lib/types"; import { createProjection, type Projection } from "../lib/projection"; import { fitRoute, findAllIntersections, bezierToSVGCubic, sampleConicCurve, conicTangentAt } from "../lib/conic-math"; // Monaco demo routes that cross each other const DEMO_ROUTES: [RouteInput, RouteInput] = [ { label: "Route A", start: { lng: 7.4267, lat: 43.7396 }, // Casino Monte-Carlo end: { lng: 7.4163, lat: 43.7275 }, // Fontvieille }, { label: "Route B", start: { lng: 7.4317, lat: 43.7519 }, // Larvotto Beach area end: { lng: 7.4197, lat: 43.7311 }, // Monaco-Ville / Palace }, ]; // Distinct shades per arc segment const COLORS_A = ["#00dcff", "#00b8d9", "#0099b3", "#007a8c"]; const COLORS_B = ["#ffb400", "#e6a200", "#cc9000", "#b37e00"]; class FolkRoutePlanner extends HTMLElement { private shadow: ShadowRoot; private routeA: FittedRoute | null = null; private routeB: FittedRoute | null = null; private intersections: IntersectionPoint[] = []; private projection: Projection | null = null; private loading = false; private error = ""; private inputA: RouteInput = DEMO_ROUTES[0]; private inputB: RouteInput = DEMO_ROUTES[1]; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.render(); } private getApiBase(): string { const path = window.location.pathname; const parts = path.split("/").filter(Boolean); if (parts.length >= 2 && parts[1] === "trips") { return `/${parts[0]}/trips`; } return "/demo/trips"; } private async fetchRoute(input: RouteInput): Promise { const res = await fetch(`${this.getApiBase()}/api/route`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ start: input.start, end: input.end }), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Unknown error" })); throw new Error(err.error || `OSRM returned ${res.status}`); } const data = await res.json(); if (data.code !== "Ok" || !data.routes?.length) { throw new Error("No route found between the given points"); } const route = data.routes[0]; const rawCoords: GeoPoint[] = route.geometry.coordinates.map( ([lng, lat]: [number, number]) => ({ lng, lat }), ); return { rawCoords, projectedPoints: [], // filled after projection arcs: [], distance: route.distance, duration: route.duration, }; } private async compute() { this.loading = true; this.error = ""; this.render(); try { // Fetch both routes const [rawA, rawB] = await Promise.all([ this.fetchRoute(this.inputA), this.fetchRoute(this.inputB), ]); // Create unified projection from all coordinates const allGeo = [...rawA.rawCoords, ...rawB.rawCoords]; this.projection = createProjection(allGeo); // Project and fit route A rawA.projectedPoints = rawA.rawCoords.map((g) => this.projection!.toSVG(g)); rawA.arcs = fitRoute(rawA.projectedPoints); this.routeA = rawA; // Project and fit route B rawB.projectedPoints = rawB.rawCoords.map((g) => this.projection!.toSVG(g)); rawB.arcs = fitRoute(rawB.projectedPoints); this.routeB = rawB; // Find intersections this.intersections = findAllIntersections(this.routeA.arcs, this.routeB.arcs); } catch (e: any) { this.error = e.message || String(e); } this.loading = false; this.render(); } private readInputs() { const q = (sel: string) => this.shadow.querySelector(sel) as HTMLInputElement | null; const parse = (v: string | undefined, fallback: number) => { const n = parseFloat(v || ""); return isNaN(n) ? fallback : n; }; this.inputA = { label: "Route A", start: { lat: parse(q("#a-start-lat")?.value, this.inputA.start.lat), lng: parse(q("#a-start-lng")?.value, this.inputA.start.lng), }, end: { lat: parse(q("#a-end-lat")?.value, this.inputA.end.lat), lng: parse(q("#a-end-lng")?.value, this.inputA.end.lng), }, }; this.inputB = { label: "Route B", start: { lat: parse(q("#b-start-lat")?.value, this.inputB.start.lat), lng: parse(q("#b-start-lng")?.value, this.inputB.start.lng), }, end: { lat: parse(q("#b-end-lat")?.value, this.inputB.end.lat), lng: parse(q("#b-end-lng")?.value, this.inputB.end.lng), }, }; } private renderSVG(): string { if (!this.routeA || !this.routeB || !this.projection) return ""; const vb = this.projection.viewBox; let svg = ``; // Defs: grid + pulse animation svg += ` `; // Layer 1: Grid (done above) // Layer 2: Overlap zone rectangles svg += this.renderOverlapZones(this.routeA.arcs, this.routeB.arcs); // Layer 3: Ghost conic curves (dashed, low opacity) svg += this.renderGhostCurves(this.routeA.arcs, "A"); svg += this.renderGhostCurves(this.routeB.arcs, "B"); // Layer 4: Route waypoints (subtle) svg += this.renderWaypoints(this.routeA.projectedPoints, "rgba(0,220,255,0.2)"); svg += this.renderWaypoints(this.routeB.projectedPoints, "rgba(255,180,0,0.2)"); // Layer 5: Fitted arc paths (solid, distinct shades) svg += this.renderArcs(this.routeA.arcs, COLORS_A, "routeA"); svg += this.renderArcs(this.routeB.arcs, COLORS_B, "routeB"); // Layer 6: Arc labels svg += this.renderArcLabels(this.routeA.arcs, "A", COLORS_A); svg += this.renderArcLabels(this.routeB.arcs, "B", COLORS_B); // Layer 7: Intersection crossing lines svg += this.renderCrossingLines(); // Layer 8: Pulsing intersection markers + labels for (let i = 0; i < this.intersections.length; i++) { const pt = this.intersections[i].point; svg += ``; svg += ``; svg += `X${i + 1}`; } // Layer 9: Start/end markers svg += this.renderMarker(this.routeA.projectedPoints[0], "A0", "#00dcff"); svg += this.renderMarker(this.routeA.projectedPoints[this.routeA.projectedPoints.length - 1], "A1", "#00dcff"); svg += this.renderMarker(this.routeB.projectedPoints[0], "B0", "#ffb400"); svg += this.renderMarker(this.routeB.projectedPoints[this.routeB.projectedPoints.length - 1], "B1", "#ffb400"); // Legend svg += this.renderLegend(vb); svg += ``; return svg; } private renderOverlapZones(arcsA: ConicArc[], arcsB: ConicArc[]): string { let svg = ""; for (const aA of arcsA) { const bbA = this.arcBounds(aA.points); for (const aB of arcsB) { const bbB = this.arcBounds(aB.points); // Compute intersection of bounding boxes const x1 = Math.max(bbA.minX, bbB.minX); const y1 = Math.max(bbA.minY, bbB.minY); const x2 = Math.min(bbA.maxX, bbB.maxX); const y2 = Math.min(bbA.maxY, bbB.maxY); if (x2 > x1 && y2 > y1) { const pad = 8; svg += ``; } } } return svg; } private renderGhostCurves(arcs: ConicArc[], routeId: string): string { const colors = routeId === "A" ? COLORS_A : COLORS_B; let svg = ""; for (let i = 0; i < arcs.length; i++) { const arc = arcs[i]; if (arc.points.length < 2 || arc.type === "degenerate") continue; const extend = 80; const sampled = sampleConicCurve(arc.coeffs, arc.points, extend); if (sampled.length < 2) continue; let d = `M ${sampled[0].x.toFixed(1)} ${sampled[0].y.toFixed(1)}`; for (let j = 1; j < sampled.length; j++) { d += ` L ${sampled[j].x.toFixed(1)} ${sampled[j].y.toFixed(1)}`; } const color = colors[i % colors.length]; svg += ``; } return svg; } private renderWaypoints(pts: { x: number; y: number }[], color: string): string { return pts.map((p) => `` ).join(""); } private renderArcs(arcs: ConicArc[], colors: string[], id: string): string { let svg = ""; for (let i = 0; i < arcs.length; i++) { const arc = arcs[i]; if (arc.beziers.length === 0) continue; const first = arc.beziers[0].p0; let d = `M ${first.x.toFixed(1)} ${first.y.toFixed(1)} `; for (const b of arc.beziers) { d += bezierToSVGCubic(b) + " "; } const color = colors[i % colors.length]; svg += ``; } return svg; } private renderArcLabels(arcs: ConicArc[], prefix: string, colors: string[]): string { let svg = ""; for (let i = 0; i < arcs.length; i++) { const arc = arcs[i]; if (arc.points.length < 2) continue; // Label at the midpoint of the arc const midIdx = Math.floor(arc.points.length / 2); const mp = arc.points[midIdx]; const label = `${prefix}.${i + 1} ${arc.type}`; const color = colors[i % colors.length]; // Background for readability svg += ``; svg += `${label}`; } return svg; } private renderCrossingLines(): string { if (!this.routeA || !this.routeB) return ""; let svg = ""; const lineLen = 20; for (let i = 0; i < this.intersections.length; i++) { const ix = this.intersections[i]; const pt = ix.point; const arcA = this.routeA.arcs[ix.arcIndexA]; const arcB = this.routeB.arcs[ix.arcIndexB]; if (!arcA || !arcB) continue; const tA = conicTangentAt(arcA.coeffs, pt); const tB = conicTangentAt(arcB.coeffs, pt); // Draw tangent lines const colorA = COLORS_A[ix.arcIndexA % COLORS_A.length]; const colorB = COLORS_B[ix.arcIndexB % COLORS_B.length]; svg += ``; svg += ``; // Compute crossing angle const dot = Math.abs(tA.dx * tB.dx + tA.dy * tB.dy); const angle = Math.acos(Math.min(1, dot)) * (180 / Math.PI); svg += `${angle.toFixed(1)}\u00B0`; } return svg; } private renderMarker(pt: { x: number; y: number }, label: string, color: string): string { return `` + `${label}`; } private renderLegend(viewBox: string): string { // Parse viewBox to position legend in top-right const parts = viewBox.split(" ").map(Number); const vx = parts[0], vy = parts[1], vw = parts[2], vh = parts[3]; const lx = vx + vw - 155; const ly = vy + 10; let svg = ``; svg += ``; // Ghost curve svg += ``; svg += `Ghost conic`; // Fitted arc svg += ``; svg += `Fitted arc`; // Overlap zone svg += ``; svg += `Overlap zone`; // Intersection svg += ``; svg += `Intersection`; svg += ``; return svg; } private arcBounds(pts: { x: number; y: number }[]): { minX: number; maxX: number; minY: number; maxY: number } { let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (const p of pts) { if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x; if (p.y < minY) minY = p.y; if (p.y > maxY) maxY = p.y; } return { minX, maxX, minY, maxY }; } private renderInfo(): string { if (!this.routeA || !this.routeB) return ""; const fmtDist = (m: number) => m >= 1000 ? `${(m / 1000).toFixed(1)}km` : `${Math.round(m)}m`; const fmtTime = (s: number) => { const min = Math.floor(s / 60); return min > 0 ? `${min}m ${Math.round(s % 60)}s` : `${Math.round(s)}s`; }; let html = `
`; // Summary row html += `
Route A: ${this.routeA.arcs.length} arcs | ${fmtDist(this.routeA.distance)} | ${fmtTime(this.routeA.duration)} Route B: ${this.routeB.arcs.length} arcs | ${fmtDist(this.routeB.distance)} | ${fmtTime(this.routeB.duration)} ${this.intersections.length} intersection${this.intersections.length !== 1 ? "s" : ""}
`; // Arc details html += `

Route A Arcs

`; for (let i = 0; i < this.routeA.arcs.length; i++) { const arc = this.routeA.arcs[i]; html += `
A.${i + 1}: ${arc.type} e=${arc.eccentricity.toFixed(3)}
`; } html += `
`; html += `

Route B Arcs

`; for (let i = 0; i < this.routeB.arcs.length; i++) { const arc = this.routeB.arcs[i]; html += `
B.${i + 1}: ${arc.type} e=${arc.eccentricity.toFixed(3)}
`; } html += `
`; // Intersection details if (this.intersections.length > 0 && this.projection) { html += `

Intersections

`; for (let i = 0; i < this.intersections.length; i++) { const ix = this.intersections[i]; const geo = this.projection.toGeo(ix.point); html += `
X${i + 1}: ${geo.lat.toFixed(5)}, ${geo.lng.toFixed(5)} (A.${ix.arcIndexA + 1} / B.${ix.arcIndexB + 1})
`; } html += `
`; } html += `
`; return html; } private render() { this.shadow.innerHTML = `

rTrips — Route Planner

Plan paths between destinations. Fit conic arcs and find where routes intersect.

to
to
${this.error ? `
${this.error}
` : ""} ${this.loading ? `
Fetching routes and computing conic fits...
` : ""} ${this.routeA && this.routeB ? `
${this.renderSVG()}
${this.renderInfo()}
Conic: Ax\u00B2+Bxy+Cy\u00B2+Dx+Ey+F=0 | Discriminant \u0394=B\u00B2\u22124AC | Intersection via Sylvester resultant \u2192 degree-4 polynomial \u2192 companion matrix eigenvalues
` : ""} ${!this.routeA && !this.routeB && !this.loading && !this.error ? `
Enter start/end coordinates for two routes, or click Use Demo to load Monaco routes.
` : ""}
`; // Attach event listeners this.shadow.querySelector("#btn-compute")?.addEventListener("click", () => { this.readInputs(); this.compute(); }); this.shadow.querySelector("#btn-demo")?.addEventListener("click", () => { this.inputA = DEMO_ROUTES[0]; this.inputB = DEMO_ROUTES[1]; this.compute(); }); } static define() { if (!customElements.get("folk-route-planner")) { customElements.define("folk-route-planner", FolkRoutePlanner); } } } FolkRoutePlanner.define(); export { FolkRoutePlanner };