Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 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 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | 2x 2x 2x 2x 2x 19x 19x 19x 19x 15x 15x 15x 19x 19x 15x 15x 15x 15x 19x 19x 2x 2x 2x 2x 2x 2x 2x 24x 24x 24x 24x 24x 2x 13x 13x 13x 26x 26x 26x 13x 13x 13x 13x 13x 13x 2x 14x 14x 14x 14x 14x 14x 5x 5x 5x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 14x 14x 14x 2x | /**
* The rail (SPECS §27.3): beside the map, each suggestion's label at the
* height of what it concerns, with a leader to a bracket that spans it, packed
* so that no two labels overlap.
*
* It is the list, laid along the map: a reader reading down the map reads down
* the rail, and a suggestion's label is where its evidence is. Hovering a label
* lights its marks on the map, and hovering a mark lights its label — one
* highlight in two places, the stage keeping which is lit. A press frames it.
*/
import { el } from '../ui/dom'
import type { StageInk } from './draw'
/** A suggestion as the rail shows it. */
export interface RailItem {
key: string
number: number
/** Its label: kind and confidence. */
text: string
/** What it would do, in full. */
title: string
/** The stretch of the axis it concerns, lowest first, in units. */
extent: readonly [number, number]
lit: boolean
/** The one review mode is on. */
current: boolean
}
/** A label's height, and the least room between two, in pixels. */
const LABEL = 20
const GAP = 3
/** Where the brackets stand, from the rail's right edge. */
const BRACKET = 7
/** Where each label goes: at the middle of what it concerns, pushed apart, and kept inside the rail. */
export function packLabels(wanted: readonly number[], height: number, label = LABEL, gap = GAP): number[] {
const order = wanted.map((y, i) => ({ y, i })).sort((a, b) => a.y - b.y)
const top = new Array<number>(wanted.length)
let floor = 0
for (const { y, i } of order) {
top[i] = Math.max(floor, Math.min(height - label, y - label / 2))
floor = top[i] + label + gap
}
// Pushed off the bottom, the last few come back up, each above the next.
let ceiling = height - label
for (let k = order.length - 1; k >= 0; k--) {
const i = order[k].i
if (top[i] > ceiling) top[i] = ceiling
ceiling = top[i] - label - gap
}
return top.map((t) => Math.max(0, t))
}
export class Rail {
readonly element: HTMLElement
private readonly canvas: HTMLCanvasElement
private readonly labels: HTMLElement
private width = 0
private height = 0
constructor(private readonly on: { hover(key: string | null): void; pick(key: string): void }) {
this.canvas = el('canvas', { class: 'edit-rail-canvas' })
this.labels = el('div', { class: 'edit-rail-labels' })
this.element = el('div', { class: 'edit-rail', attrs: { role: 'list', 'aria-label': 'Suggestions along the map' } }, this.canvas, this.labels)
this.element.addEventListener('pointerleave', () => this.on.hover(null))
}
/** Where the rail sits in the plot, in CSS pixels. */
place(x: number, y: number, width: number, height: number, dpr: number): void {
this.width = width
this.height = height
for (const node of [this.element, this.canvas]) {
node.style.width = `${width}px`
node.style.height = `${height}px`
}
this.element.style.left = `${x}px`
this.element.style.top = `${y}px`
this.canvas.width = Math.round(width * dpr)
this.canvas.height = Math.round(height * dpr)
this.canvas.getContext('2d')?.setTransform(dpr, 0, 0, dpr, 0, 0)
}
/** Draw the items along the map, whose rows `toY` places. */
paint(items: readonly RailItem[], toY: (unit: number) => number, ink: StageInk): void {
const width = this.width
const height = this.height
const ctx = this.canvas.getContext('2d')
ctx?.clearRect(0, 0, width, height)
// Only what is in view: a label pointing off the map points at nothing.
const shown = items
.map((item) => {
const a = toY(item.extent[0])
const b = toY(item.extent[1])
return { item, y0: Math.max(0, Math.min(a, b)), y1: Math.min(height, Math.max(a, b)), inView: Math.max(a, b) >= 0 && Math.min(a, b) <= height }
})
.filter((row) => row.inView)
const tops = packLabels(
shown.map((row) => (row.y0 + row.y1) / 2),
height,
)
const bracketX = width - BRACKET
const labelRight = width - BRACKET - 12
if (ctx !== null) {
ctx.lineCap = 'round'
shown.forEach((row, i) => {
const strong = row.item.lit || row.item.current
ctx.globalAlpha = strong ? 1 : 0.55
ctx.strokeStyle = ink.accent
ctx.lineWidth = strong ? 2.5 : 1.5
// The bracket: what it concerns, along the map's rows.
const y0 = row.y0
const y1 = Math.max(row.y1, y0 + 2)
ctx.beginPath()
ctx.moveTo(bracketX - 4, y0)
ctx.lineTo(bracketX, y0)
ctx.lineTo(bracketX, y1)
ctx.lineTo(bracketX - 4, y1)
ctx.stroke()
// The leader, from the label to the bracket's middle.
const ly = tops[i] + LABEL / 2
const my = (y0 + y1) / 2
ctx.lineWidth = strong ? 1.5 : 1
ctx.beginPath()
ctx.moveTo(labelRight, ly)
ctx.lineTo(labelRight + 5, ly)
ctx.lineTo(bracketX - 5, my)
ctx.stroke()
ctx.globalAlpha = 1
})
}
this.labels.replaceChildren(
...shown.map((row, i) => {
const { item } = row
const label = el('button', {
class: `edit-rail-label${item.lit ? ' is-lit' : ''}${item.current ? ' is-current' : ''}`,
title: `${item.title} — press to frame it`,
attrs: { type: 'button', role: 'listitem', 'data-key': item.key },
style: { top: `${tops[i]}px`, right: `${width - labelRight}px`, height: `${LABEL}px` },
on: {
pointerenter: () => this.on.hover(item.key),
focus: () => this.on.hover(item.key),
blur: () => this.on.hover(null),
click: () => this.on.pick(item.key),
},
})
label.append(el('span', { class: 'edit-rail-number', text: String(item.number) }), el('span', { text: item.text }))
return label
}),
)
}
}
|