// Berth chart (time-space diagram): x = hours, y = quay sections (port numbering). // One rectangle per ship; closures/alongside blocks hatched; published slot as a dashed ghost; // delay tail in red from due to departure; conflicts outlined red; a vertical line for the current hour. import { callKinds, clockAt, cranePoolAt, departureAfter, divertWindows, escapeHtml, hasCranes, movesOf, publishedSlot } from "./model.js"; const NS = "http://www.w3.org/2000/svg"; let uid = 0; function el(tag, attrs = {}, parent) { const e = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs)) if (v != null) e.setAttribute(k, String(v)); if (parent) parent.appendChild(e); return e; } export class BerthChart { constructor(container, { mini = false, onHover, onPick, onScrub, maxHeight, editable = false, onMove, onCranes, fill = false } = {}) { this.container = container; this.onCranes = onCranes; this.mini = mini; this.fill = fill; this.editable = editable && !mini; this.onMove = onMove; this.onHover = onHover; this.onPick = onPick; this.onScrub = onScrub; this.maxHeight = maxHeight; this.id = `bc${++uid}`; this.time = 0; this.hover = null; this.selected = null; this.svg = el("svg", { class: mini ? "bchart mini" : this.editable ? "bchart edit" : "bchart", role: "img" }); container.appendChild(this.svg); if (this.editable) { container.tabIndex = 0; container.dataset.keys = "chart"; container.setAttribute("aria-label", "Plan editor: drag a ship to change its docking hour (left/right) and first section (up/down); arrow keys nudge the selected ship; + and - change its cranes"); } this.ro = new ResizeObserver(() => { const w = container.clientWidth; if (!w || Math.abs(w - (this._w || 0)) < 1) return; cancelAnimationFrame(this._rf); this._rf = requestAnimationFrame(() => this.render()); }); this.ro.observe(container); this._bind(); } destroy() { this.ro.disconnect(); this.svg.remove(); } setData(task, evaluation, horizon) { this.task = task; this.ev = evaluation; this.H = Math.max(1, horizon); this.render(); } setMaxHeight(h) { if (h != null && this.maxHeight != null && Math.abs(h - this.maxHeight) < 1) return; this.maxHeight = h; this.render(); } setTime(t) { this.time = t; if (this.nowLine && this.geom) { const x = this.geom.x(Math.max(0, Math.min(this.H, t))); this.nowLine.setAttribute("x1", x); this.nowLine.setAttribute("x2", x); if (this.nowHead) this.nowHead.setAttribute("transform", `translate(${x},${this.geom.top - 1})`); } } setHover(id) { this.hover = id; this._marks(); } setSelected(id) { this.selected = id; this._marks(); } _marks() { if (!this.shipEls) return; for (const [id, g] of this.shipEls) { g.classList.toggle("hov", this.hover === id); g.classList.toggle("sel", this.selected === id); } } render() { const task = this.task; const svg = this.svg; while (svg.firstChild) svg.removeChild(svg.firstChild); if (!task || !this.ev) return; const W = Math.max(200, this.container.clientWidth); this._w = this.container.clientWidth; const n = task.last_section - task.first_section + 1; const mini = this.mini; const cr = hasCranes(task); const rules = task.rules || {}; const winds = (rules.no_moves || []).slice().sort((a, b) => (b.min_length || 0) - (a.min_length || 0)); const outages = rules.crane_outages || []; const diverts = divertWindows(task); const lane = !mini && (diverts.length || winds.length || outages.length); const left = mini ? 2 : W < 520 ? 24 : 30; const right = mini ? 2 : 8; const top = mini ? 2 : lane ? 46 : 20; const strips = cr && !mini && this.ev.series; const STRIP_C = 28; const STRIP_M = 20; const stripsH = strips ? 8 + STRIP_C + 6 + STRIP_M : 0; const bottom = mini ? 2 : 4; let rowH = mini ? Math.max(2, Math.min(4, 84 / n)) : this.editable ? (W < 560 ? 11 : 15) : W < 560 ? 9 : 12; if (!mini && this.maxHeight) { const fit = Math.floor((this.maxHeight - top - bottom - stripsH) / n); rowH = this.fill ? Math.max(8, Math.min(18, fit)) : Math.max(6, Math.min(rowH, fit)); } const plotB = top + n * rowH; const Hpx = plotB + stripsH + bottom; svg.setAttribute("viewBox", `0 0 ${W} ${Hpx}`); svg.setAttribute("width", W); svg.setAttribute("height", Hpx); const H = this.H; const x = (h) => left + (h / H) * (W - left - right); const y = (s) => top + (s - task.first_section) * rowH; this.geom = { x, y, top, bottom: plotB, left, right: W - right, W, rowH, n }; const defs = el("defs", {}, svg); const pat = (name, cls, size = 5) => { const p = el("pattern", { id: `${this.id}-${name}`, patternUnits: "userSpaceOnUse", width: size, height: size, patternTransform: "rotate(45)" }, defs); el("rect", { width: size, height: size, class: `${cls}-bg` }, p); el("line", { x1: 0, y1: 0, x2: 0, y2: size, class: `${cls}-ln` }, p); }; pat("closed", "pc"); pat("along", "pa"); pat("divert", "pd"); pat("hold", "ph", 4); pat("out", "po", 4); const kinds = callKinds(task); // grid: day bands, 6 h ticks, section lines const grid = el("g", { class: "grid" }, svg); for (let d = 0; d * 24 < H; d++) { const x0 = x(d * 24); const x1 = x(Math.min(H, (d + 1) * 24)); if (d % 2 === 1) el("rect", { x: x0, y: top, width: x1 - x0, height: n * rowH, class: "band" }, grid); if (!mini) { const c = clockAt(task, d * 24); const label = x1 - x0 > 54 ? `${c.day} ${c.date.split(" ")[0]}` : c.day; if (x1 - x0 > 22) el("text", { x: (x0 + x1) / 2, y: 13, class: "ax ax-day", "text-anchor": "middle" }, grid).textContent = label; } } // wind windows: vertical bands through the whole chart (and the strips) for (const w of winds) { const all = !(w.min_length > 0); const wx0 = x(Math.max(0, w.start)); const ww = Math.max(1, x(Math.min(H, w.end)) - wx0); const g = el("g", { class: `wind ${all ? "w-all" : "w-long"}` }, grid); el("rect", { x: wx0, y: mini ? 0 : top, width: ww, height: (mini ? Hpx : plotB + stripsH - top), class: "wind-r" }, g); el("title", {}, g).textContent = `Hours ${w.start}-${w.end}: ${w.reason || "wind"}: ${all ? "no ship" : `no ship of ${w.min_length} m or more`} may dock or leave. Cranes keep working; a ship that finishes inside waits alongside.`; } if (!mini) { for (let h = 6; h < H; h += 6) { el("line", { x1: x(h), x2: x(h), y1: top, y2: plotB, class: h % 24 === 0 ? "vday" : "v6" }, grid); } const every = rowH >= 11 ? 1 : 2; for (let sct = task.first_section; sct <= task.last_section; sct++) { if (sct > task.first_section) el("line", { x1: left, x2: W - right, y1: y(sct), y2: y(sct), class: "hsec" }, grid); if ((sct - task.first_section) % every === 0) el("text", { x: left - 4, y: y(sct) + rowH / 2 + 3, class: "ax ax-sec", "text-anchor": "end" }, grid).textContent = sct; } el("rect", { x: left, y: top, width: W - left - right, height: n * rowH, class: "frame" }, grid); // event lane above the plot: diverts, crane outages, wind labels const laneY = top - 9; const laneLabels = []; const laneText = (x0, x1, txt, cls) => laneLabels.push({ x0, x1, txt, cls }); for (const d of diverts) { const dx0 = x(Math.max(0, d.start)); const dw = Math.max(1, x(Math.min(H, d.end)) - dx0); const g = el("g", { class: "divert" }, grid); el("rect", { x: dx0, y: laneY, width: dw, height: 6, fill: `url(#${this.id}-divert)`, class: "dv-r" }, g); el("title", {}, g).textContent = `Quay ${d.from_quay} closed hours ${d.start}-${d.end}; ships ${(d.ships || []).join(", ")} diverted to this quay`; laneText(dx0, dx0 + dw, `${d.from_quay} closed · ${(d.ships || []).length} diverted here`, "dv-t"); } for (const o of outages) { const ox0 = x(Math.max(0, o.start)); const ow = Math.max(1, x(Math.min(H, o.end)) - ox0); const g = el("g", { class: "outage" }, grid); el("rect", { x: ox0, y: laneY, width: ow, height: 6, fill: `url(#${this.id}-out)`, class: "ou-r" }, g); el("title", {}, g).textContent = `Hours ${o.start}-${o.end}: ${o.cranes} of ${rules.crane_pool} quay cranes out of service`; laneText(ox0, ox0 + ow, `${o.cranes} cranes out`, "ou-t"); } for (const w of winds) { const wx0 = x(Math.max(0, w.start)); const wx1 = x(Math.min(H, w.end)); const kn = (String(w.reason || "").match(/(\d+)\s*kn/) || [])[1]; laneText(wx0, wx1, `${kn ? `${kn} kn` : "wind"} · ${w.min_length > 0 ? `≥${w.min_length} m` : "all ships"}`, w.min_length > 0 ? "wl-t" : "wa-t"); } // place lane labels left to right without overlapping (two text rows if needed) laneLabels.sort((a, b) => a.x0 - b.x0); const rowsEnd = [-Infinity, -Infinity]; for (const L of laneLabels) { const wTxt = L.txt.length * 5.1; let tx = L.x0; if (tx + wTxt > W - right) tx = Math.max(left, W - right - wTxt); const r = tx > rowsEnd[0] ? 0 : tx > rowsEnd[1] ? 1 : -1; if (r < 0) continue; rowsEnd[r] = tx + wTxt + 6; el("text", { x: tx, y: r === 0 ? laneY - 3 : laneY - 13, class: `ax lane-t ${L.cls}` }, grid).textContent = L.txt; } } // blocks const blocks = el("g", {}, svg); for (const b of task.blocks || []) { const bx = x(Math.max(0, b.start)); const bw = x(Math.min(H, b.end)) - bx; const by = y(b.first); const bh = (b.last - b.first + 1) * rowH; const g = el("g", { class: `blk ${b.kind}` }, blocks); el("rect", { x: bx, y: by, width: Math.max(1, bw), height: bh, fill: `url(#${this.id}-${b.kind === "closed" ? "closed" : "along"})`, class: "blk-r" }, g); el("title", {}, g).textContent = b.kind === "closed" ? `Closed sections ${b.first}-${b.last}, hours ${b.start}-${b.end}: ${b.label}` : `${b.label} already alongside sections ${b.first}-${b.last} until hour ${b.end}${b.cranes ? `, working ${b.cranes} cranes` : ""}`; if (!mini && bw > 40 && bh >= 9) { const label = b.kind === "closed" ? `closed · ${b.label}` : `${b.label}${b.cranes ? ` · ${b.cranes} cr` : ""}`; this._text(g, bx + 4, by + Math.min(bh / 2, 10) + 3, label, bw - 8, "blk-t"); } } // published ghosts if (!mini) { const ghosts = el("g", { class: "ghosts" }, svg); for (const sh of task.ships) { const slot = publishedSlot(sh); if (!slot) continue; const row = this.ev.byId.get(sh.id); if (row && row.placed && row.berth === slot.start && row.section === slot.first && row.dep === slot.end) continue; el("rect", { x: x(slot.start), y: y(slot.first) + 0.5, width: Math.max(1, x(slot.end) - x(slot.start)), height: sh.sections * rowH - 1, class: "ghost" }, ghosts); } } // ships const ships = el("g", { class: "ships" }, svg); this.shipEls = new Map(); this.hits = []; for (const row of this.ev.rows) { if (!row.placed) continue; const sh = row.ship; const rx = x(row.berth); const rw = Math.max(1.5, x(row.dep) - rx); const ry = y(row.section) + 0.5; const rh = sh.sections * rowH - 1; const kind = kinds.get(sh.id); const g = el("g", { class: `ship${kind ? ` extra ${kind.kind}` : ""}${row.conflicts.length ? " bad" : ""}${(sh.weight || 1) > 1 ? " prio" : ""}${sh.berth_deadline != null ? " emerg" : ""}`, "data-id": sh.id }, ships); if (!mini && row.berth > sh.arrival) { const cy = ry + rh / 2; el("line", { x1: x(sh.arrival), x2: rx, y1: cy, y2: cy, class: "wait" }, g); el("line", { x1: x(sh.arrival), x2: x(sh.arrival), y1: cy - 3, y2: cy + 3, class: "wait" }, g); } el("rect", { x: rx, y: ry, width: rw, height: rh, class: "ship-r" }, g); if (row.hold > 0) { const hx = x(row.finish); el("rect", { x: hx, y: ry, width: Math.max(1, x(row.dep) - hx), height: rh, fill: `url(#${this.id}-hold)`, class: "hold" }, g); } if (row.delay > 0) { const tx = x(Math.max(row.berth, sh.due)); el("rect", { x: tx, y: ry, width: Math.max(1, x(row.dep) - tx), height: rh, class: "late" }, g); } if (row.berth < sh.arrival && !mini) { el("line", { x1: x(sh.arrival), x2: x(sh.arrival), y1: ry, y2: ry + rh, class: "early" }, g); } if (sh.berth_deadline != null) { const dx = x(sh.berth_deadline); el("line", { x1: dx, x2: dx, y1: ry - 2, y2: ry + rh + 2, class: `ddl${row.late ? " missed" : ""}` }, g); } el("rect", { x: rx, y: ry, width: rw, height: rh, class: "ship-o" }, g); if (!mini && rw > 26 && rh >= 8) { const bits = []; if ((sh.weight || 1) > 1) bits.push(`×${sh.weight}`); bits.push(kind && kind.kind === "divert" && rw > 110 ? `${sh.name} · from ${kind.from}` : sh.name); let label = bits.join(" "); if (cr) label += ` · ${row.cranes} cr`; if (sh.berth_deadline != null && rw > 150) label += ` · dock by h ${sh.berth_deadline}`; this._text(g, rx + 3, ry + Math.min(rh / 2, 9) + 3, label, rw - 5, "ship-t"); } const kindTip = kind ? (kind.kind === "divert" ? ` (diverted from ${kind.from})` : " (unscheduled call)") : ""; const tip = [ `${sh.id} ${sh.name}${kindTip}`, `docks at hour ${row.berth}, sections ${row.section}-${row.last}${cr ? `, ${row.cranes} cranes (${sh.min_cranes}-${sh.max_cranes})` : ""}`, cr ? `${movesOf(task, sh)} moves: ${row.work} h of work, done at hour ${row.finish}${row.hold ? `, then held by the wind until hour ${row.dep}` : ""}` : null, `arrives ${sh.arrival}, leaves ${row.dep}, due ${sh.due}`, row.delay > 0 ? `${row.delay} h late${(sh.weight || 1) > 1 ? ` (priority: counts ×${sh.weight})` : ""}` : "on time", sh.berth_deadline != null ? `emergency: must dock by hour ${sh.berth_deadline}${row.late ? `, docks ${row.berth - sh.berth_deadline} h late (+${sh.deadline_penalty} per hour)` : ""}` : null, row.moved ? "moved from its published sections" : null, ...row.conflicts, ].filter(Boolean).join("\n"); el("title", {}, g).textContent = tip; this.shipEls.set(sh.id, g); this.hits.push({ id: sh.id, x0: rx, x1: rx + rw, y0: ry, y1: ry + rh, area: rw * rh }); if (this.editable) g.classList.add("drag"); } this.hits.sort((a, b) => a.area - b.area); if (strips) this._strips(svg, x, plotB + 8, STRIP_C, STRIP_M, left, W - right); // now line const nx = x(Math.max(0, Math.min(H, this.time))); this.nowLine = el("line", { x1: nx, x2: nx, y1: mini ? 0 : top - 2, y2: plotB + stripsH, class: "now" }, svg); this.nowHead = mini ? null : el("path", { d: "M-4,-5 L4,-5 L0,0 Z", class: "now-h", transform: `translate(${nx},${top - 1})` }, svg); this._marks(); } /** Strips under the chart: cranes in use vs available, and ship movements per hour vs the limit. */ _strips(svg, x, y0, hc, hm, xl, xr) { const task = this.task; const ser = this.ev.series; const H = Math.floor(this.H); const g = el("g", { class: "strips" }, svg); const pool = Number(task.rules.crane_pool); let maxC = pool; for (let t = 0; t < H; t++) maxC = Math.max(maxC, ser.use.get(t) || 0); const yc = (v) => y0 + hc - (v / maxC) * (hc - 2); el("rect", { x: xl, y: y0, width: xr - xl, height: hc, class: "strip-bg" }, g); // available (pool less outages) as a step area let d = `M${x(0)},${y0 + hc}`; for (let t = 0; t < H; t++) { const a = cranePoolAt(task, t); d += ` L${x(t)},${yc(a)} L${x(t + 1)},${yc(a)}`; } d += ` L${x(H)},${y0 + hc} Z`; el("path", { d, class: "avail" }, g); // in use, merged into runs of equal value let t = 0; while (t < H) { const u = ser.use.get(t) || 0; const overT = u > cranePoolAt(task, t); let t1 = t + 1; while (t1 < H && (ser.use.get(t1) || 0) === u && u > cranePoolAt(task, t1) === overT && cranePoolAt(task, t1) === cranePoolAt(task, t)) t1++; if (u > 0) { const r = el("rect", { x: x(t), y: yc(u), width: Math.max(0.8, x(t1) - x(t)), height: y0 + hc - yc(u), class: overT ? "use over" : "use" }, g); el("title", {}, r).textContent = `hours ${t}-${t1}: ${u} cranes in use, ${cranePoolAt(task, t)} available${overT ? " (over the pool)" : ""}`; } t = t1; } el("text", { x: xl + 3, y: y0 + 9, class: "ax strip-t" }, g).textContent = `cranes in use / available (pool ${pool})`; // moves per hour vs limit const y1 = y0 + hc + 6; const cap = ser.cap || 0; let maxM = Math.max(cap + 1, 2); for (const v of ser.moves.values()) maxM = Math.max(maxM, v); const ym = (v) => y1 + hm - (v / maxM) * (hm - 2); el("rect", { x: xl, y: y1, width: xr - xl, height: hm, class: "strip-bg" }, g); const bw = Math.max(1.2, (xr - xl) / this.H - 0.3); for (const [hh, v] of ser.moves) { if (!v || hh < 0 || hh > this.H) continue; const r = el("rect", { x: x(hh) - bw / 2 + ((xr - xl) / this.H) / 2, y: ym(v), width: bw, height: y1 + hm - ym(v), class: cap && v > cap ? "mv over" : "mv" }, g); el("title", {}, r).textContent = `hour ${hh}: ${v} ship${v > 1 ? "s" : ""} dock or leave${cap ? ` (limit ${cap})` : ""}`; } if (cap) el("line", { x1: xl, x2: xr, y1: ym(cap), y2: ym(cap), class: "cap" }, g); el("text", { x: xl + 3, y: y1 + 8, class: "ax strip-t" }, g).textContent = `moves per hour${cap ? ` (limit ${cap})` : ""}`; } _text(g, tx, ty, str, maxW, cls) { const per = this.geom.rowH >= 11 ? 5.6 : 5.0; const fit = Math.floor(maxW / per); if (fit < 3) return; const s = str.length > fit ? `${str.slice(0, Math.max(1, fit - 1))}…` : str; el("text", { x: tx, y: ty, class: cls }, g).textContent = s; } _hourAt(clientX) { const r = this.svg.getBoundingClientRect(); const g = this.geom; const px = ((clientX - r.left) / r.width) * g.W; return Math.max(0, Math.min(this.H, ((px - g.left) / (g.right - g.left)) * this.H)); } _shipAt(clientX, clientY) { if (!this.hits || !this.geom) return null; const r = this.svg.getBoundingClientRect(); const scale = r.width / this.geom.W || 1; const px = (clientX - r.left) / scale; const py = (clientY - r.top) / scale; for (const h of this.hits) if (px >= h.x0 - 1 && px <= h.x1 + 1 && py >= h.y0 && py <= h.y1) return h.id; return null; } _rowOf(id) { return this.ev && this.ev.byId.get(id); } /** Clamp a proposed placement to the quay and the chart's hour range. */ _clampPlace(row, hour, section) { const t = this.task; const n = row.ship.sections; return { hour: Math.max(0, Math.min(Math.max(0, Math.floor(this.H) - (row.work || row.ship.handling)), Math.round(hour))), section: Math.max(t.first_section, Math.min(t.last_section - n + 1, Math.round(section))), }; } _preview(id, hour, section) { const g = this.shipEls && this.shipEls.get(id); const row = this._rowOf(id); if (!g || !row || !this.geom) return; const dx = this.geom.x(hour) - this.geom.x(row.berth); const dy = (section - row.section) * this.geom.rowH; g.setAttribute("transform", `translate(${dx},${dy})`); g.classList.add("dragging"); if (!this.dragLbl) this.dragLbl = el("text", { class: "drag-t" }, this.svg); const last = section + row.ship.sections - 1; const fin = hour + (row.work || row.ship.handling); const dep = hasCranes(this.task) ? departureAfter(this.task, row.ship, fin) : fin; const txt = `h ${hour} · ${section}–${last} · leaves h ${dep}${dep > fin ? " (wind)" : ""}`; this.dragLbl.textContent = txt; const lx = Math.min(this.geom.right - txt.length * 5.6 - 2, Math.max(this.geom.left + 2, this.geom.x(hour))); const ly = this.geom.y(section) - 3 < this.geom.top + 8 ? this.geom.y(last + 1) + 11 : this.geom.y(section) - 3; this.dragLbl.setAttribute("x", lx); this.dragLbl.setAttribute("y", ly); this.svg.appendChild(this.dragLbl); } _clearPreview() { if (this.dragLbl) { this.dragLbl.remove(); this.dragLbl = null; } } _bind() { let drag = null; const scale = () => { const r = this.svg.getBoundingClientRect(); return r.width / this.geom.W || 1; }; this.svg.addEventListener("pointermove", (e) => { if (!this.geom) return; if (drag && drag.kind === "ship") { const k = scale(); const pxPerHour = (this.geom.right - this.geom.left) / this.H; const dh = (e.clientX - drag.x) / k / pxPerHour; const ds = (e.clientY - drag.y) / k / this.geom.rowH; if (!drag.moved && Math.hypot(e.clientX - drag.x, e.clientY - drag.y) < 4) return; drag.moved = true; const p = this._clampPlace(drag.row, drag.h0 + dh, drag.s0 + ds); if (p.hour !== drag.hour || p.section !== drag.section) { drag.hour = p.hour; drag.section = p.section; this._preview(drag.id, p.hour, p.section); if (this.onMove) this.onMove(drag.id, p.hour, p.section, false); } return; } if (drag && this.onScrub && !this.mini) { if (Math.abs(e.clientX - drag.x) > 3) drag.moved = true; if (drag.moved) this.onScrub(this._hourAt(e.clientX)); return; } const id = this._shipAt(e.clientX, e.clientY); if (id !== this._hov) { this._hov = id; this.svg.style.cursor = id != null ? (this.editable ? "grab" : "pointer") : this.mini ? "pointer" : "crosshair"; if (this.onHover) this.onHover(id); } }); this.svg.addEventListener("pointerleave", () => { if (drag) return; if (this._hov != null && this.onHover) this.onHover(null); this._hov = null; }); this.svg.addEventListener("pointerdown", (e) => { if (!this.geom || this.mini) return; const id = this.editable ? this._shipAt(e.clientX, e.clientY) : null; if (id != null) { const row = this._rowOf(id); if (!row || !row.placed) return; e.preventDefault(); this.container.focus({ preventScroll: true }); drag = { kind: "ship", id, row, x: e.clientX, y: e.clientY, h0: row.berth, s0: row.section, hour: row.berth, section: row.section, moved: false }; this.svg.style.cursor = "grabbing"; } else { drag = { kind: "time", x: e.clientX, moved: false }; } this.svg.setPointerCapture(e.pointerId); }); const end = (e, cancelled) => { if (this.mini) { if (!cancelled && this.onPick) this.onPick(null, null); return; } if (!drag) return; const d = drag; drag = null; try { this.svg.releasePointerCapture(e.pointerId); } catch {} if (d.kind === "ship") { this._clearPreview(); this.svg.style.cursor = "grab"; if (d.moved) { if (this.onMove) this.onMove(d.id, d.hour, d.section, true); else this.render(); } else if (!cancelled && this.onPick) this.onPick(null, d.id); return; } if (d.moved || cancelled) return; const h = this._hourAt(e.clientX); const id = this._shipAt(e.clientX, e.clientY); if (this.onPick) this.onPick(h, id); }; this.svg.addEventListener("pointerup", (e) => end(e, false)); this.svg.addEventListener("pointercancel", (e) => end(e, true)); if (this.editable) { this.container.addEventListener("keydown", (e) => { if (this.selected == null || typeof this.selected !== "number") return; const row = this._rowOf(this.selected); if (!row || !row.placed) return; const step = e.shiftKey ? 6 : 1; let h = row.berth; let sec = row.section; if (e.key === "ArrowLeft") h -= step; else if (e.key === "ArrowRight") h += step; else if (e.key === "ArrowUp") sec -= 1; else if (e.key === "ArrowDown") sec += 1; else if ((e.key === "+" || e.key === "=" || e.key === "-" || e.key === "_") && this.onCranes && hasCranes(this.task)) { e.preventDefault(); e.stopPropagation(); this.onCranes(row.id, e.key === "-" || e.key === "_" ? -1 : 1); return; } else return; e.preventDefault(); e.stopPropagation(); const p = this._clampPlace(row, h, sec); if (this.onMove && (p.hour !== row.berth || p.section !== row.section)) this.onMove(row.id, p.hour, p.section, true); }); } } } export function legendHtml(opts = {}) { const sw = (cls) => ``; return [ `${sw("lg-ship")} ship`, `${sw("lg-late")} late`, `${sw("lg-bad")} conflict`, `${sw("lg-ghost")} published`, `${sw("lg-closed")} closed`, `${sw("lg-along")} alongside`, `${sw("lg-extra")} extra / diverted`, ...(opts.cranes ? [ `${sw("lg-hold")} held by wind`, `${sw("lg-wind")} wind`, `${sw("lg-ddl")} dock-by`, ] : []), ].join(""); } export { escapeHtml };