rspace-online/modules/trips/components/folk-route-planner.ts

532 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<FittedRoute> {
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 = `<svg viewBox="${vb}" xmlns="http://www.w3.org/2000/svg" class="route-svg">`;
// Defs: grid + pulse animation
svg += `<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="rgba(255,255,255,0.05)" stroke-width="0.5"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)"/>`;
// 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 += `<circle cx="${pt.x.toFixed(1)}" cy="${pt.y.toFixed(1)}" r="8" fill="none" stroke="#ff3355" stroke-width="1.5" class="pulse-ring"/>`;
svg += `<circle cx="${pt.x.toFixed(1)}" cy="${pt.y.toFixed(1)}" r="5" fill="#ff3355" stroke="#fff" stroke-width="1.5" class="pulse-dot"/>`;
svg += `<text x="${(pt.x + 12).toFixed(1)}" y="${(pt.y - 10).toFixed(1)}" fill="#ff3355" font-size="11" font-family="monospace" font-weight="bold">X${i + 1}</text>`;
}
// 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 += `</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 += `<rect x="${(x1 - pad).toFixed(1)}" y="${(y1 - pad).toFixed(1)}" width="${(x2 - x1 + pad * 2).toFixed(1)}" height="${(y2 - y1 + pad * 2).toFixed(1)}" fill="rgba(255,0,200,0.06)" stroke="rgba(255,0,200,0.15)" stroke-width="0.5" stroke-dasharray="4 3" rx="3"/>`;
}
}
}
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 += `<path d="${d}" fill="none" stroke="${color}" stroke-width="1" stroke-dasharray="5 4" opacity="0.25" stroke-linecap="round"/>`;
}
return svg;
}
private renderWaypoints(pts: { x: number; y: number }[], color: string): string {
return pts.map((p) =>
`<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="1.5" fill="${color}"/>`
).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 += `<path d="${d}" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.85" class="arc-path" data-id="${id}-${i}"/>`;
}
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 += `<rect x="${(mp.x + 5).toFixed(1)}" y="${(mp.y - 11).toFixed(1)}" width="${(label.length * 6.2 + 6).toFixed(0)}" height="14" rx="3" fill="rgba(13,13,26,0.8)"/>`;
svg += `<text x="${(mp.x + 8).toFixed(1)}" y="${(mp.y).toFixed(1)}" fill="${color}" font-size="9" font-family="monospace" opacity="0.8">${label}</text>`;
}
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 += `<line x1="${(pt.x - tA.dx * lineLen).toFixed(1)}" y1="${(pt.y - tA.dy * lineLen).toFixed(1)}" x2="${(pt.x + tA.dx * lineLen).toFixed(1)}" y2="${(pt.y + tA.dy * lineLen).toFixed(1)}" stroke="${colorA}" stroke-width="1.5" opacity="0.7" stroke-linecap="round"/>`;
svg += `<line x1="${(pt.x - tB.dx * lineLen).toFixed(1)}" y1="${(pt.y - tB.dy * lineLen).toFixed(1)}" x2="${(pt.x + tB.dx * lineLen).toFixed(1)}" y2="${(pt.y + tB.dy * lineLen).toFixed(1)}" stroke="${colorB}" stroke-width="1.5" opacity="0.7" stroke-linecap="round"/>`;
// 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 += `<text x="${(pt.x + 14).toFixed(1)}" y="${(pt.y + 18).toFixed(1)}" fill="rgba(255,255,255,0.6)" font-size="8" font-family="monospace">${angle.toFixed(1)}°</text>`;
}
return svg;
}
private renderMarker(pt: { x: number; y: number }, label: string, color: string): string {
return `<circle cx="${pt.x.toFixed(1)}" cy="${pt.y.toFixed(1)}" r="5" fill="${color}" stroke="#1a1a2e" stroke-width="1.5"/>` +
`<text x="${(pt.x + 8).toFixed(1)}" y="${(pt.y + 4).toFixed(1)}" fill="${color}" font-size="10" font-family="monospace" font-weight="bold">${label}</text>`;
}
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 = `<g transform="translate(${lx.toFixed(0)},${ly.toFixed(0)})">`;
svg += `<rect x="0" y="0" width="150" height="78" rx="5" fill="rgba(13,13,26,0.85)" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/>`;
// Ghost curve
svg += `<line x1="8" y1="14" x2="30" y2="14" stroke="#aaa" stroke-width="1" stroke-dasharray="4 3" opacity="0.5"/>`;
svg += `<text x="36" y="17" fill="rgba(255,255,255,0.5)" font-size="8" font-family="monospace">Ghost conic</text>`;
// Fitted arc
svg += `<line x1="8" y1="28" x2="30" y2="28" stroke="#00dcff" stroke-width="2.5" opacity="0.85"/>`;
svg += `<text x="36" y="31" fill="rgba(255,255,255,0.5)" font-size="8" font-family="monospace">Fitted arc</text>`;
// Overlap zone
svg += `<rect x="8" y="38" width="22" height="10" fill="rgba(255,0,200,0.12)" stroke="rgba(255,0,200,0.3)" stroke-width="0.5" rx="2"/>`;
svg += `<text x="36" y="47" fill="rgba(255,255,255,0.5)" font-size="8" font-family="monospace">Overlap zone</text>`;
// Intersection
svg += `<circle cx="19" cy="63" r="4" fill="#ff3355" stroke="#fff" stroke-width="1" class="pulse-dot"/>`;
svg += `<text x="36" y="66" fill="rgba(255,255,255,0.5)" font-size="8" font-family="monospace">Intersection</text>`;
svg += `</g>`;
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 = `<div class="info-grid">`;
// Summary row
html += `<div class="info-summary">
<span class="tag cyan">Route A: ${this.routeA.arcs.length} arcs | ${fmtDist(this.routeA.distance)} | ${fmtTime(this.routeA.duration)}</span>
<span class="tag amber">Route B: ${this.routeB.arcs.length} arcs | ${fmtDist(this.routeB.distance)} | ${fmtTime(this.routeB.duration)}</span>
<span class="tag red">${this.intersections.length} intersection${this.intersections.length !== 1 ? "s" : ""}</span>
</div>`;
// Arc details
html += `<div class="arc-details"><h4>Route A Arcs</h4>`;
for (let i = 0; i < this.routeA.arcs.length; i++) {
const arc = this.routeA.arcs[i];
html += `<div class="arc-item cyan">A.${i + 1}: <strong>${arc.type}</strong> e=${arc.eccentricity.toFixed(3)}</div>`;
}
html += `</div>`;
html += `<div class="arc-details"><h4>Route B Arcs</h4>`;
for (let i = 0; i < this.routeB.arcs.length; i++) {
const arc = this.routeB.arcs[i];
html += `<div class="arc-item amber">B.${i + 1}: <strong>${arc.type}</strong> e=${arc.eccentricity.toFixed(3)}</div>`;
}
html += `</div>`;
// Intersection details
if (this.intersections.length > 0 && this.projection) {
html += `<div class="arc-details"><h4>Intersections</h4>`;
for (let i = 0; i < this.intersections.length; i++) {
const ix = this.intersections[i];
const geo = this.projection.toGeo(ix.point);
html += `<div class="arc-item red">X${i + 1}: ${geo.lat.toFixed(5)}, ${geo.lng.toFixed(5)} (A.${ix.arcIndexA + 1} / B.${ix.arcIndexB + 1})</div>`;
}
html += `</div>`;
}
html += `</div>`;
return html;
}
private render() {
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: -apple-system, system-ui, sans-serif; color: #e0e0e0; }
.container { background: #1a1a2e; border-radius: 12px; overflow: hidden; }
.rapp-nav { padding: 16px 20px; border-bottom: 1px solid rgba(255,255,255,0.08); }
.rapp-nav__title { font-size: 15px; font-weight: 600; color: #e2e8f0; display: block; margin-bottom: 4px; }
.rapp-nav__desc { font-size: 13px; color: #94a3b8; }
.input-panel { padding: 16px 20px; display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; border-bottom: 1px solid rgba(255,255,255,0.08); }
.route-group { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.route-group label { font-size: 12px; font-weight: 600; min-width: 52px; }
.route-group.a label { color: #00dcff; }
.route-group.b label { color: #ffb400; }
input { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); border-radius: 6px; padding: 6px 8px; color: #e0e0e0; font-size: 12px; font-family: monospace; width: 88px; }
input:focus { outline: none; border-color: rgba(255,255,255,0.3); }
input::placeholder { color: rgba(255,255,255,0.3); }
.btn-row { display: flex; gap: 8px; }
button { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); border-radius: 6px; padding: 6px 14px; color: #e0e0e0; font-size: 12px; cursor: pointer; transition: background 0.15s; }
button:hover { background: rgba(255,255,255,0.14); }
button.primary { background: rgba(0,220,255,0.15); border-color: rgba(0,220,255,0.3); color: #00dcff; }
button.primary:hover { background: rgba(0,220,255,0.25); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
.svg-container { padding: 12px; background: #12121f; }
.route-svg { width: 100%; height: auto; max-height: 500px; border-radius: 8px; background: #0d0d1a; }
.arc-path:hover { stroke-width: 4; opacity: 1; }
@keyframes pulse { 0%,100% { opacity: 0.9; r: 5; } 50% { opacity: 0.5; r: 8; } }
@keyframes pulse-ring { 0%,100% { opacity: 0.6; r: 8; } 50% { opacity: 0; r: 14; } }
.pulse-dot { animation: pulse 2s ease-in-out infinite; }
.pulse-ring { animation: pulse-ring 2s ease-in-out infinite; }
.error { padding: 12px 20px; color: #ff5577; font-size: 13px; }
.loading { padding: 20px; text-align: center; color: rgba(255,255,255,0.5); font-size: 13px; }
.empty { padding: 40px 20px; text-align: center; color: rgba(255,255,255,0.3); font-size: 14px; }
.info-grid { padding: 16px 20px; }
.info-summary { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
.tag { padding: 4px 10px; border-radius: 12px; font-size: 12px; font-family: monospace; }
.tag.cyan { background: rgba(0,220,255,0.12); color: #00dcff; }
.tag.amber { background: rgba(255,180,0,0.12); color: #ffb400; }
.tag.red { background: rgba(255,51,85,0.12); color: #ff3355; }
.arc-details { margin-bottom: 8px; }
.arc-details h4 { margin: 0 0 4px 0; font-size: 12px; opacity: 0.5; text-transform: uppercase; letter-spacing: 0.5px; }
.arc-item { font-size: 12px; font-family: monospace; padding: 2px 0; }
.arc-item.cyan { color: rgba(0,220,255,0.7); }
.arc-item.amber { color: rgba(255,180,0,0.7); }
.arc-item.red { color: rgba(255,51,85,0.8); }
.math-note { padding: 12px 20px; border-top: 1px solid rgba(255,255,255,0.05); font-size: 11px; opacity: 0.4; font-family: monospace; }
</style>
<div class="container">
<div class="rapp-nav">
<span class="rapp-nav__title">Route Planner</span>
<span class="rapp-nav__desc">Plan paths between destinations. Fit conic arcs and find where routes intersect.</span>
</div>
<div class="input-panel">
<div class="route-group a">
<label>Route A</label>
<input id="a-start-lat" value="${this.inputA.start.lat}" placeholder="lat" title="Start latitude">
<input id="a-start-lng" value="${this.inputA.start.lng}" placeholder="lng" title="Start longitude">
<span style="opacity:0.3">to</span>
<input id="a-end-lat" value="${this.inputA.end.lat}" placeholder="lat" title="End latitude">
<input id="a-end-lng" value="${this.inputA.end.lng}" placeholder="lng" title="End longitude">
</div>
<div class="route-group b">
<label>Route B</label>
<input id="b-start-lat" value="${this.inputB.start.lat}" placeholder="lat" title="Start latitude">
<input id="b-start-lng" value="${this.inputB.start.lng}" placeholder="lng" title="Start longitude">
<span style="opacity:0.3">to</span>
<input id="b-end-lat" value="${this.inputB.end.lat}" placeholder="lat" title="End latitude">
<input id="b-end-lng" value="${this.inputB.end.lng}" placeholder="lng" title="End longitude">
</div>
<div class="btn-row">
<button class="primary" id="btn-compute" ${this.loading ? "disabled" : ""}>Compute</button>
<button id="btn-demo">Use Demo</button>
</div>
</div>
${this.error ? `<div class="error">${this.error}</div>` : ""}
${this.loading ? `<div class="loading">Fetching routes and computing conic fits...</div>` : ""}
${this.routeA && this.routeB ? `
<div class="svg-container">${this.renderSVG()}</div>
${this.renderInfo()}
<div class="math-note">
Conic: Ax²+Bxy+Cy²+Dx+Ey+F=0 | Discriminant Δ=B²4AC |
Intersection via Sylvester resultant → degree-4 polynomial → companion matrix eigenvalues
</div>
` : ""}
${!this.routeA && !this.routeB && !this.loading && !this.error ? `
<div class="empty">
Enter start/end coordinates for two routes, or click <strong>Use Demo</strong> to load Monaco routes.
</div>
` : ""}
</div>`;
// 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 };