Spaces:
Running
Running
Download openenv/berth_openenv/web/chart.js from FineEnvs/PortSimEnv: direct link, hf CLI and curl.
- Browser
- Download file 26.3 kB
-
https://huggingface.co/spaces/FineEnvs/PortSimEnv/resolve/main/openenv/berth_openenv/web/chart.js
- Command line
-
hf download hf://spaces/FineEnvs/PortSimEnv/openenv/berth_openenv/web/chart.js
-
curl -L -o chart.js https://huggingface.co/spaces/FineEnvs/PortSimEnv/resolve/main/openenv/berth_openenv/web/chart.js
26.3 kB
| // 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) => `<i class="sw ${cls}"></i>`; | |
| return [ | |
| `<span title="Ship alongside in this plan">${sw("lg-ship")} ship</span>`, | |
| `<span title="Hours past the ship's due departure">${sw("lg-late")} late</span>`, | |
| `<span title="Overlap, docking before arrival, a closed section, or (crane rules) cranes over the pool, too many moves in an hour, docking in a wind window">${sw("lg-bad")} conflict</span>`, | |
| `<span title="Slot in the published plan">${sw("lg-ghost")} published</span>`, | |
| `<span title="Sections closed for works">${sw("lg-closed")} closed</span>`, | |
| `<span title="Ship already alongside at hour 0 (cannot move)">${sw("lg-along")} alongside</span>`, | |
| `<span title="Unscheduled extra call, or a call diverted from the other quay">${sw("lg-extra")} extra / diverted</span>`, | |
| ...(opts.cranes ? [ | |
| `<span title="Finished but held alongside: it may not leave inside a wind window">${sw("lg-hold")} held by wind</span>`, | |
| `<span title="Wind windows: no ship may dock or leave (darker: all ships; lighter: ships of 300 m or more)">${sw("lg-wind")} wind</span>`, | |
| `<span title="Emergency: must dock by the red line">${sw("lg-ddl")} dock-by</span>`, | |
| ] : []), | |
| ].join(""); | |
| } | |
| export { escapeHtml }; | |