blob: 5868da34678241a77c7c96ca98e15bc81fb8c0e3 [file] [log] [blame]
/**
* @module gantt
* @description Tools for displaying Gantt charts.
*/
import { strDuration } from 'common-sk/modules/human';
import { render, svg } from 'lit-html';
import { $$ } from 'common-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,
);
}