summaryrefslogtreecommitdiff
path: root/web/frontend/ride-detail-boundaries.ts
blob: 22a1255d8ce69fe63da2860367d0275a51e69a1e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
import { climbMetrics, formatDistance, formatElevation } from "./ride-detail-logic.js";
import { requireElement } from "./dom.js";
import type { BoundarySource, BoundaryTarget, ClimbBounds, RideProfilePoint } from "./types.js";

interface ActiveBoundary {
  form: HTMLFormElement;
  target: BoundaryTarget;
  source: BoundarySource;
}

interface RideBoundaryControllerOptions {
  forms: HTMLFormElement[];
  points: RideProfilePoint[];
  climbBounds: ClimbBounds[];
  onBoundaryChanged: (index: number) => void;
  onPointSelected: (index: number) => void;
  setMessage: (message: string) => void;
}

export class RideBoundaryController {
  private readonly forms: HTMLFormElement[];
  private readonly points: RideProfilePoint[];
  private readonly climbBounds: ClimbBounds[];
  private readonly onBoundaryChanged: (index: number) => void;
  private readonly onPointSelected: (index: number) => void;
  private readonly setMessage: (message: string) => void;
  private activeBoundary: ActiveBoundary | null;

  constructor({ forms, points, climbBounds, onBoundaryChanged, onPointSelected, setMessage }: RideBoundaryControllerOptions) {
    this.forms = forms;
    this.points = points;
    this.climbBounds = climbBounds;
    this.onBoundaryChanged = onBoundaryChanged;
    this.onPointSelected = onPointSelected;
    this.setMessage = setMessage;
    this.activeBoundary = null;
    this.initialize();
  }

  initialize(): void {
    for (const form of this.forms) {
      const item = form.closest<HTMLElement>("[data-climb-item]");
      if (!item) continue;
      const climbIndex = Number.parseInt(item.dataset.climbIndex ?? "", 10);
      this.climbBounds[climbIndex] = {
        startIndex: Number.parseInt(
          requireElement(form.querySelector<HTMLInputElement>('[data-boundary-input="start"]'), "start boundary input").value,
          10,
        ),
        endIndex: Number.parseInt(
          requireElement(form.querySelector<HTMLInputElement>('[data-boundary-input="end"]'), "end boundary input").value,
          10,
        ),
      };
      this.updatePreview(form);
      const buttons = [...form.querySelectorAll<HTMLElement>("[data-boundary-button]")];
      for (const button of buttons) {
        button.addEventListener("click", () => {
          this.clearSelection();
          this.activeBoundary = {
            form,
            target: button.dataset.boundaryButton as BoundaryTarget,
            source: button.dataset.boundarySource as BoundarySource,
          };
          button.classList.add("active");
          const surface = this.activeBoundary.source === "map" ? "map" : "profile";
          this.setMessage(`Click the ${surface} to select the ${this.activeBoundary.target} point.`);
        });
      }
    }
  }

  isSelecting(source: BoundarySource): boolean {
    return this.activeBoundary?.source === source;
  }

  clearSelection(): void {
    this.activeBoundary = null;
    for (const form of this.forms) {
      for (const button of form.querySelectorAll("[data-boundary-button]")) button.classList.remove("active");
    }
  }

  choosePoint(index: number): void {
    if (!this.activeBoundary) return;
    const { form, target } = this.activeBoundary;
    const item = form.closest<HTMLElement>("[data-climb-item]");
    if (!item) return;
    const climbIndex = Number.parseInt(item.dataset.climbIndex ?? "", 10);
    const input = requireElement(form.querySelector<HTMLInputElement>(`[data-boundary-input="${target}"]`), `${target} boundary input`);
    const output = requireElement(form.querySelector<HTMLOutputElement>(`[data-boundary-output="${target}"]`), `${target} boundary output`);
    input.value = String(index);
    output.textContent = this.boundaryLabel(index);
    if (target === "start") this.climbBounds[climbIndex].startIndex = index;
    else this.climbBounds[climbIndex].endIndex = index;
    this.updatePreview(form);
    this.onBoundaryChanged(climbIndex);
    this.clearSelection();
    this.onPointSelected(index);
  }

  updatePreview(form: HTMLFormElement): void {
    const preview = requireElement(form.querySelector<HTMLElement>("[data-boundary-preview]"), "boundary preview");
    const item = form.closest<HTMLElement>("[data-climb-item]");
    if (!item) return;
    const climbIndex = Number.parseInt(item.dataset.climbIndex ?? "", 10);
    const metrics = climbMetrics(this.points, this.climbBounds[climbIndex]);
    if (!metrics) {
      preview.textContent = "Choose an end point after the start point.";
      return;
    }
    const summary = requireElement(item.querySelector<HTMLElement>("[data-climb-summary]"), "climb summary");
    const metricsOutput = requireElement(item.querySelector<HTMLElement>("[data-climb-metrics]"), "climb metrics");
    summary.textContent = `${formatDistance(metrics.start.distanceKm)}–${formatDistance(metrics.end.distanceKm)}`;
    metricsOutput.textContent = `${metrics.category} · ${formatDistance(metrics.distanceKm)} at ${metrics.slope.toFixed(1)}% · Cotacol ${metrics.cotacol.toFixed(1)}`;
    preview.textContent = `Preview: ${formatDistance(metrics.distanceKm)} · ${metrics.elevationGain >= 0 ? "+" : ""}${formatElevation(metrics.elevationGain)} · ${metrics.slope.toFixed(1)}% · Cotacol ${metrics.cotacol.toFixed(1)}`;
  }

  boundaryLabel(index: number): string {
    const point = this.points[index];
    return `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`;
  }
}