| /** |
| * @module gantt |
| * @description Tools for displaying Gantt charts. |
| */ |
| |
| import { render, svg } from 'lit/html.js'; |
| import { strDuration } from '../../../infra-sk/modules/human'; |
| import { $$ } from '../../../infra-sk/modules/dom'; |
| |
| export interface Block { |
| start: Date; |
| end: Date; |
| color?: string; |
| label?: string; |
| } |
| |
| export interface Lane { |
| label: string; |
| blocks: Block[]; |
| } |
| |
| export interface Data { |
| lanes: Lane[]; |
| start?: Date; |
| end?: Date; |
| epochs?: Date[]; |
| } |
| |
| interface DisplayBlock { |
| x: number; |
| y: number; |
| width: number; |
| height: number; |
| title: string; |
| color: string; |
| } |
| |
| interface Label { |
| text: string; |
| x: number; |
| y: number; |
| width: number; |
| } |
| |
| interface Epoch { |
| x: number; |
| y: number; |
| width: number; |
| height: number; |
| color: string; |
| } |
| |
| interface RulerTick { |
| x1: number; |
| y1: number; |
| x2: number; |
| y2: number; |
| } |
| |
| interface RulerText { |
| x: number; |
| y: number; |
| rotationX: number; |
| rotationY: number; |
| text: string; |
| } |
| |
| interface Border { |
| x1: number; |
| y1: number; |
| x2: number; |
| y2: number; |
| } |
| |
| const blockHeightProportion = 0.8; |
| const chartMarginLeft = 5; |
| const chartMarginRight = 110; |
| const chartMarginY = 5; |
| const labelFontFamily = 'Arial'; |
| const labelFontSize = 11; |
| const labelHeight = 20; |
| const labelMarginRight = 10; |
| const mouseoverHeight = 50; |
| const rulerHeight = 78; |
| const rulerLabelMarginRight = 5; |
| const rulerLabelRotation = -30; |
| const rulerTickLength = 15; |
| const snapThreshold = 15; |
| |
| /** |
| * Draw a chart as a child of the given HTMLElement. |
| */ |
| export function draw(container: HTMLElement, data: Data) { |
| // Calculate the space needed to display the labels. |
| const boundingRect = container.getBoundingClientRect(); |
| const totalWidth = boundingRect.width; |
| const totalHeight = boundingRect.height; |
| const blocksHeight = |
| totalHeight - rulerHeight - mouseoverHeight - 2 * chartMarginY; |
| const rowHeight = blocksHeight / data.lanes.length; |
| const blockHeight = rowHeight * blockHeightProportion; |
| const blockMarginY = (rowHeight - blockHeight) / 2; |
| const labelMarginY = (rowHeight - labelHeight) / 2; |
| |
| // This is a temporary throwaway canvas which is only used for measuring |
| // text. |
| const canvas = document.createElement('canvas'); |
| const ctx = canvas.getContext('2d')!; |
| ctx.font = `${labelFontSize}px ${labelFontFamily}`; |
| let maxLabelWidth = 0; |
| data.lanes.forEach((lane: Lane) => { |
| const width = ctx.measureText(lane.label).width; |
| if (width > maxLabelWidth) { |
| maxLabelWidth = width; |
| } |
| }); |
| |
| // Define the chart area. |
| const blocksStartX = chartMarginLeft + maxLabelWidth + labelMarginRight; |
| const blocksStartY = chartMarginY + mouseoverHeight; |
| const blocksWidth = totalWidth - blocksStartX - chartMarginRight; |
| |
| // Find the time range which encompasses all blocks. |
| let tStart = data.start?.getTime() || Number.MAX_SAFE_INTEGER; |
| let tEnd = data.end?.getTime() || 0; |
| data.lanes.forEach((lane: Lane) => { |
| lane.blocks.forEach((block: Block) => { |
| const start = new Date(block.start).getTime(); |
| if (start < tStart) { |
| tStart = start; |
| } |
| if (start > tEnd) { |
| tEnd = start; |
| } |
| const end = new Date(block.end).getTime(); |
| if (end < tStart) { |
| tStart = end; |
| } |
| if (end > tEnd) { |
| tEnd = end; |
| } |
| }); |
| }); |
| if (tStart > tEnd) { |
| throw `Start timestamp (${tStart}) is after end (${tEnd})`; |
| } |
| const duration = tEnd - tStart; |
| const timeStart = tStart; |
| |
| // Derive the coordinates for the blocks and their labels. |
| const blocks: DisplayBlock[] = []; |
| const labels: Label[] = []; |
| data.lanes.forEach((lane: Lane, laneIndex: number) => { |
| labels.push({ |
| text: lane.label, |
| x: chartMarginLeft + maxLabelWidth, |
| y: blocksStartY + laneIndex * rowHeight + labelHeight / 2 + labelMarginY, |
| width: maxLabelWidth, |
| }); |
| lane.blocks.forEach((block: Block) => { |
| const start = block.start.getTime(); |
| const end = block.end.getTime(); |
| let title = ''; |
| if (block.label) { |
| title = `${block.label} `; |
| } |
| title += strDuration((end - start) / 1000); |
| blocks.push({ |
| x: blocksStartX + (blocksWidth * (start - tStart)) / duration, |
| y: blocksStartY + laneIndex * rowHeight + blockMarginY, |
| width: Math.max((blocksWidth * (end - start)) / duration, 1.0), |
| height: blockHeight, |
| title: title, |
| color: block.color || '#000000', |
| }); |
| }); |
| }); |
| |
| // Create the ruler. |
| // We want approximately one tick every 50-100 px. |
| const numTargetTicks = blocksWidth / 75; |
| const approxTickSize = duration / numTargetTicks; |
| // Round the tick size to the nearest multiple of an appropriate unit. |
| // Timestamps are in milliseconds. |
| const units = [ |
| 1, // 1 ms |
| 5, // 5 ms |
| 10, // 10 ms |
| 50, // 50 ms |
| 100, // 100 ms |
| 500, // 500 ms |
| 1000, // 1 s |
| 5000, // 5 s |
| 10000, // 10 s |
| 30000, // 30 s |
| 60000, // 1 m |
| 300000, // 5 m |
| 600000, // 10 m |
| 1800000, // 30 m |
| 3600000, // 1 h |
| 10800000, // 3 h |
| 21600000, // 6 h |
| 43200000, // 12 h |
| 86400000, // 1 d |
| ]; |
| let lowestDist = -1; |
| let actualTickSize = units[0]; |
| for (const unit of units) { |
| const dist = Math.abs(approxTickSize - unit); |
| if (lowestDist === -1 || dist < lowestDist) { |
| lowestDist = dist; |
| actualTickSize = unit; |
| } |
| } |
| // Find an "anchor" for the ticks to start. We want the ticks to be on a |
| // nice round hour/minute/second/millisecond, so take the start timestamp |
| // and truncate to the day. |
| const tickAnchor = new Date(tStart); |
| tickAnchor.setHours(0); |
| tickAnchor.setMinutes(0); |
| tickAnchor.setSeconds(0); |
| tickAnchor.setMilliseconds(0); |
| // Create the ticks. The first tick is the first multiple of the tick size |
| // which comes after tStart. |
| const numTicksPastAnchor = Math.ceil( |
| (tStart - tickAnchor.getTime()) / actualTickSize |
| ); |
| let tick = tickAnchor.getTime() + numTicksPastAnchor * actualTickSize; |
| const ticks = []; |
| while (tick < tEnd) { |
| ticks.push(tick); |
| tick += actualTickSize; |
| } |
| |
| // Create background blocks for each epoch. |
| const epochs = data.epochs || []; |
| // Ensure that there's an epoch block which reaches to the end of the chart. |
| epochs.push(new Date(tEnd)); |
| const normEpochs: Epoch[] = []; |
| const epochColors = ['#EFEFEF', '#EAEAEA']; |
| let lastX = blocksStartX; |
| for (let i = 0; i < epochs.length; i++) { |
| const epoch = epochs[i].getTime(); |
| if (epoch >= tStart && epoch <= tEnd) { |
| const x = blocksStartX + (blocksWidth * (epoch - tStart)) / duration; |
| normEpochs.push({ |
| x: lastX, |
| y: blocksStartY, |
| width: x - lastX, |
| height: blocksHeight, |
| color: epochColors[i % epochColors.length], |
| }); |
| lastX = x; |
| } |
| } |
| |
| const rulerTicks: RulerTick[] = []; |
| const rulerTexts: RulerText[] = []; |
| let lastDate = new Date(ticks[0]).getDate(); |
| for (const tick of ticks) { |
| const x = blocksStartX + (blocksWidth * (tick - tStart)) / duration; |
| const y1 = blocksStartY + blocksHeight; |
| const y2 = y1 + rulerTickLength; |
| rulerTicks.push({ |
| x1: x, |
| y1: y1, |
| x2: x, |
| y2: y2, |
| }); |
| const d = new Date(tick); |
| rulerTexts.push({ |
| x: x - rulerLabelMarginRight, |
| y: y2, |
| rotationX: x, |
| rotationY: y2, |
| text: |
| d.getDate() === lastDate ? d.toLocaleTimeString() : d.toLocaleString(), |
| }); |
| lastDate = d.getDate(); |
| } |
| |
| // Draw border lines around the chart. |
| const borders = [ |
| { |
| x1: blocksStartX, |
| y1: blocksStartY, |
| x2: blocksStartX, |
| y2: blocksStartY + blocksHeight, |
| }, |
| { |
| x1: blocksStartX, |
| y1: blocksStartY + blocksHeight, |
| x2: blocksStartX + blocksWidth, |
| y2: blocksStartY + blocksHeight, |
| }, |
| ]; |
| |
| // Event handlers. |
| let dragStartX: number | undefined; |
| |
| // Helper function for finding the x-value and timestamp given a mouse |
| // event. |
| const getMouseX = (e: MouseEvent): number => { |
| // Convert event x-coordinate to a coordinate within the chart area. |
| let x = e.clientX - boundingRect!.x; |
| if (x < blocksStartX) { |
| x = blocksStartX; |
| } else if (x > boundingRect!.width - chartMarginRight) { |
| x = boundingRect!.width - chartMarginRight; |
| } |
| // Find the nearest block border; if we're close enough, snap the line. |
| let nearest = 0; |
| let nearestDist = blocksWidth; |
| for (const block of blocks) { |
| let dist = Math.abs(block.x - x); |
| if (dist < nearestDist) { |
| nearest = block.x; |
| nearestDist = dist; |
| } |
| dist = Math.abs(block.x + block.width - x); |
| if (dist < nearestDist) { |
| nearest = block.x + block.width; |
| nearestDist = dist; |
| } |
| } |
| if (nearestDist < snapThreshold) { |
| x = nearest; |
| } |
| return x; |
| }; |
| |
| // Helper function for finding the timestamp associated with the given |
| // x-coordinate on the chart. |
| const getTimeAtMouseX = (x: number): Date => |
| new Date(timeStart + ((x - blocksStartX) / blocksWidth) * duration); |
| |
| // Create a vertical line used on mouseover. This is a helper function used |
| // by the mousemove callback function. |
| const mouseMoved = (e: MouseEvent) => { |
| const svg = $$<SVGElement>('svg', container); |
| if (!svg) { |
| return; |
| } |
| |
| // Update the vertical cursor line. |
| const x = getMouseX(e); |
| const mouseLine = $$<SVGLineElement>('#mouse-line', svg)!; |
| mouseLine.setAttributeNS(null, 'x1', `${x}`); |
| mouseLine.setAttributeNS(null, 'x2', `${x}`); |
| |
| // Update the timestamp for the cursor. |
| const ts = getTimeAtMouseX(x); |
| const mouseTime = $$<SVGTextElement>('#mouse-text', svg)!; |
| mouseTime.setAttributeNS(null, 'x', `${x}`); |
| mouseTime.innerHTML = ts.toLocaleTimeString(); |
| |
| // If we're selecting, update the selection box. |
| if (dragStartX !== undefined) { |
| let x1 = dragStartX; |
| let x2 = x; |
| if (x2 < x1) { |
| x2 = x1; |
| x1 = x; |
| } |
| const selectRect = $$<SVGRectElement>('#select-rect', svg)!; |
| const selectText = $$<SVGTextElement>('#select-text', svg)!; |
| const selectLineStart = $$<SVGLineElement>('#select-line-start', svg)!; |
| const selectLineEnd = $$<SVGLineElement>('#select-line-end', svg)!; |
| |
| selectRect.setAttributeNS(null, 'x', `${x1}`); |
| selectRect.setAttributeNS(null, 'width', `${x2 - x1}`); |
| selectLineStart.setAttributeNS(null, 'x1', `${x1}`); |
| selectLineStart.setAttributeNS(null, 'x2', `${x1}`); |
| selectLineEnd.setAttributeNS(null, 'x1', `${x2}`); |
| selectLineEnd.setAttributeNS(null, 'x2', `${x2}`); |
| |
| // Update the selected time range label. |
| const selectedDuration = |
| getTimeAtMouseX(x2).getTime() - getTimeAtMouseX(x1).getTime(); |
| selectText.setAttributeNS(null, 'x', `${(x1 + x2) / 2}`); |
| selectText.innerHTML = strDuration(selectedDuration / 1000); |
| |
| // The cursor time label interferes with the selected time labels. |
| // make it disappear if we're actively selecting. |
| mouseTime.innerHTML = ''; |
| } |
| }; |
| |
| // Create a selection box when the mouse is clicked and dragged. This is a |
| // helper function used by the mousedown callback function. |
| const mouseSelectStart = (e: MouseEvent) => { |
| const svg = $$<SVGElement>('svg', container); |
| if (!svg) { |
| return; |
| } |
| const selectRect = $$<SVGRectElement>('#select-rect', svg)!; |
| const selectLineStart = $$<SVGLineElement>('#select-line-start', svg)!; |
| const selectLineEnd = $$<SVGLineElement>('#select-line-end', svg)!; |
| |
| const x = getMouseX(e); |
| |
| selectRect.setAttributeNS(null, 'x', `${x}`); |
| selectLineStart.setAttributeNS(null, 'x1', `${x}`); |
| selectLineStart.setAttributeNS(null, 'x2', `${x}`); |
| selectLineEnd.setAttributeNS(null, 'x1', `${x}`); |
| selectLineEnd.setAttributeNS(null, 'x2', `${x}`); |
| |
| dragStartX = x; |
| }; |
| |
| const mouseOut = (e: MouseEvent) => { |
| dragStartX = undefined; |
| }; |
| |
| // Render. |
| render( |
| svg` |
| <svg |
| width="${totalWidth}" |
| height="${totalHeight}" |
| @mousemove="${(e: MouseEvent) => { |
| mouseMoved(e); |
| }}" |
| @mousedown="${(e: MouseEvent) => { |
| mouseSelectStart(e); |
| }}" |
| @mouseup="${(e: MouseEvent) => { |
| mouseOut(e); |
| }}" |
| @mouseleave="${(e: MouseEvent) => { |
| mouseOut(e); |
| }}" |
| > |
| ${normEpochs.map( |
| (epoch: Epoch) => svg` |
| <rect |
| x="${epoch.x}" |
| y="${epoch.y}" |
| width="${epoch.width}" |
| height="${epoch.height}" |
| class="epoch" |
| > |
| </rect> |
| ` |
| )} |
| ${labels.map( |
| (lbl: Label) => svg` |
| <text |
| alignment-baseline="middle" |
| text-anchor="end" |
| x="${lbl.x}" |
| y="${lbl.y}" |
| width="${lbl.width}" |
| height="${labelHeight}" |
| > |
| ${lbl.text} |
| </text> |
| ` |
| )} |
| ${blocks.map( |
| (block: DisplayBlock) => svg` |
| <rect |
| x="${block.x}" |
| y="${block.y}" |
| width="${block.width}" |
| height="${block.height}" |
| fill="${block.color}" |
| > |
| <title>${block.title}</title> |
| </rect> |
| ` |
| )} |
| ${borders.map( |
| (b: Border) => svg` |
| <line |
| x1="${b.x1}" |
| y1="${b.y1}" |
| x2="${b.x2}" |
| y2="${b.y2}" |
| > |
| </line> |
| ` |
| )} |
| ${rulerTicks.map( |
| (tick: RulerTick) => svg` |
| <line |
| x1="${tick.x1}" |
| y1="${tick.y1}" |
| x2="${tick.x2}" |
| y2="${tick.y2}" |
| > |
| </line> |
| ` |
| )} |
| ${rulerTexts.map( |
| (text: RulerText) => svg` |
| <text |
| alignment-baseline="middle" |
| text-anchor="end" |
| x="${text.x}" |
| y="${text.y}" |
| transform="rotate(${rulerLabelRotation} ${text.rotationX} ${text.rotationY})" |
| > |
| ${text.text} |
| </text> |
| ` |
| )} |
| <line |
| id="mouse-line" |
| x1="-1000" |
| y1="${blocksStartY - 10}" |
| x2="-1000" |
| y2="${blocksStartY + blocksHeight}" |
| > |
| </line> |
| <text |
| id="mouse-text" |
| alignment-baseline="bottom" |
| text-anchor="middle" |
| x="-1000" |
| y="${blocksStartY - 20}" |
| > |
| </text> |
| <rect |
| id="select-rect" |
| fill="red" |
| fill-opacity="0.2" |
| x="-1000" |
| y="${blocksStartY}" |
| width="0" |
| height="${blocksHeight}" |
| > |
| </rect> |
| <text |
| id="select-text" |
| alignment-baseline="bottom" |
| text-anchor="middle" |
| x="-1000" |
| y="${blocksStartY - 10}" |
| > |
| </text> |
| <line |
| id="select-line-start" |
| x1="-1000" |
| y1="${blocksStartY - 10}" |
| x2="-1000" |
| y2="${blocksStartY}" |
| > |
| </line> |
| <line |
| id="select-line-end" |
| x1="-1000" |
| y1="${blocksStartY - 10}" |
| x2="-1000" |
| y2="${blocksStartY}" |
| > |
| </line> |
| </svg> |
| `, |
| container |
| ); |
| } |