Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Timeline #142

Merged
merged 25 commits into from
Nov 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions timeline/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.txt
140 changes: 140 additions & 0 deletions timeline/cursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* This file contains the code to build the cursor highlight on the timeline.
*/

import {from} from 'fromit';
import {Timeline} from 'vis-timeline';
import type * as Rx from 'rxjs';

export function buildCursor(timeline: Timeline, options: {
eventAdd: Rx.Observable<number>,
}) {

const cursorBox = document.createElement('div');
cursorBox.append(document.createElement('sl-spinner'));
cursorBox.addEventListener('dblclick', async function(e) {
cursorBox.classList.add('cursor-loading');
});
options.eventAdd.subscribe(() => cursorBox.classList.remove('cursor-loading'));

const foreground = document.querySelector('.vis-center .vis-foreground') as HTMLElement;
cursorBox.className = 'cursor-selection';
foreground.appendChild(cursorBox);
foreground.addEventListener('mouseleave', function() {
cursorBox.style.display = 'none';
});



foreground.addEventListener('mousemove', function(e: MouseEvent) {
const target = e.target as HTMLElement;
if (target === cursorBox) {
return;
}
// If the target is inside the cursorBox also do nothing.
if (cursorBox.contains(target)) {
return;
}

// Get the x position in regards of the parent.
const x = e.clientX - foreground.getBoundingClientRect().left;

// Make sure target has vis-group class.
if (!target.classList.contains('vis-group')) {
cursorBox.style.display = 'none';
return;
}
cursorBox.style.display = 'grid';

// Now get the element at that position but in another div.
const anotherDiv = document.querySelector(
'div.vis-panel.vis-background.vis-vertical > div.vis-time-axis.vis-background'
)!;

const anotherDivPos = anotherDiv.getBoundingClientRect().left;

if (!leftPoints.length) {
const children = Array.from(anotherDiv.children);
// Group by week number, element will have class like vis-week4

// Get current scale from timeline.
const weekFromElement = (el: Element) => {
const classList = Array.from(el.classList)
.filter(f => !['vis-even', 'vis-odd', 'vis-minor', 'vis-major'].includes(f))
return classList.join(' ');
};

// Group by class attribute.
const grouped = from(children)
.groupBy(e => weekFromElement(e))
.toArray();

// Map to { left: the left of the first element in group, width: total width of the group }
const points = grouped.map(group => {
const left = group.first().getBoundingClientRect().left;
const width = group.reduce(
(acc, el) => acc + el.getBoundingClientRect().width,
0
);
const adjustedWidth = left - anotherDivPos;
return {left: adjustedWidth, width};
});

leftPoints = points;
}

// Find the one that is closest to the x position, so the first after.
const index = leftPoints.findLastIndex(c => c.left < x);

// Find its index.
const closest = leftPoints[index];

// Now get the element from the other div.


// Now reposition selection.
cursorBox.style.left = `${closest.left}px`;
cursorBox.style.width = `${closest.width}px`;
cursorBox.style.transform = '';
try {
if (cursorBox.parentElement !== target) {
target.prepend(cursorBox);
}
} catch (ex) {}
});




// Helper to track cursor box on the timeline while scrolling.
let leftPoints = [] as {left: number; width: number}[];
timeline.on('rangechange', ({byUser, event}) => {
leftPoints = [];
if (!byUser) {
return;
}
if (!event || !event.deltaX) {
return;
}
const deltaX = event.deltaX;
cursorBox.style.transform = `translateX(${deltaX}px)`;
});
timeline.on('rangechanged', () => {
// Try to parse transform, and get the x value.
const transform = cursorBox.style.transform;
if (!transform) {
return;
}
const match = transform.match(/translateX\(([^)]+)\)/);
if (!match) {
return;
}
const x = parseFloat(match[1]);

// Update left property of the cursorBox, by adding the delta.
const left = parseFloat(cursorBox.style.left);
cursorBox.style.left = `${left + x}px`;
cursorBox.style.transform = '';
})

}
224 changes: 224 additions & 0 deletions timeline/header.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import {computed, dom, Observable, observable} from 'grainjs';
import {Timeline} from 'vis-timeline';
import type * as Rx from 'rxjs';
import {buildColLabel, Command, withElementSpinner} from './lib';

export class HeaderMonitor {
private headerMonitor!: ResizeObserver;
private lastWidth = 0;
private work = () => {};
constructor() {
this.work = debounced(this.invoke.bind(this), 5);
}
public start() {
// Monitor left panel and adjust header left position.
this.headerMonitor = new ResizeObserver(this.work);
this.headerMonitor.observe(this.panel());
}
public invoke(...args: any[]) {
const [panel, header] = [this.panel(), this.header()];
const {left} = panel.querySelector('.vis-labelset')!.getBoundingClientRect();
header.style.setProperty('--left-width', `${left}px`);
const width = args[0][0].borderBoxSize[0].inlineSize;
header.style.width = `${width}px`;
if (this.lastWidth !== width) {
this.lastWidth = width;
recalculateHeader();
}
}

private panel() {
return document.querySelector('.vis-panel.vis-left')!;
}

private header() {
return document.getElementById('groupHeader')!;
}

private pauseCalls = 0;

public pause() {
this.pauseCalls++;
if (this.pauseCalls === 1) {
this.headerMonitor.disconnect();
}
}

public resume() {
this.pauseCalls--;
if (this.pauseCalls === 0) {
this.headerMonitor.observe(this.panel());
}
if (this.pauseCalls < 0) {
throw new Error('Unbalanced pause/resume calls');
}
}
}

export const headerMonitor = new HeaderMonitor();

export function rewriteHeader({mappings, timeline, cmdAddBlank}: {
mappings: Observable<any>;
timeline: Timeline;
cmdAddBlank: Command;
}) {

try {
headerMonitor.pause();

const headerTop = document.querySelector('#groupHeader .top')! as HTMLElement;
const headerRight = document.querySelector('#groupHeader .bottom .right')! as HTMLElement;

const columnsDiv = dom('div');
columnsDiv.classList.add('group-header-columns');

const columnElements = mappings.get().Columns.map((col: string) => {
return dom('div',
dom.text(buildColLabel(col)),
dom.cls('group-part'),
dom.style('padding', '5px')
);
});
columnsDiv.style.setProperty('grid-template-columns', 'auto');

const moreDiv = document.createElement('div');
moreDiv.style.width = '20px';

columnElements.push(moreDiv);
const collapsed = observable(false);

const iconName = computed(use => {
return use(collapsed) ? 'chevron-bar-right' : 'chevron-bar-left';
})

const resizer = headerTop.querySelector('.resizer') as HTMLElement;
const icon = headerTop.querySelector('sl-icon') as HTMLElement;
const button = headerTop.querySelector('sl-button') as HTMLElement;
const buttonLoading = observable(false);
dom.update(resizer,
dom.on('click', () => collapsed.set(!collapsed.get()))
);
dom.update(icon,
// Icon to hide or show drawer.
dom.prop('name', iconName)
);
dom.update(button,
dom.prop('loading', buttonLoading),
dom.on('click', async () => {
try {
buttonLoading.set(true);
await cmdAddBlank.invoke(null);
} finally {
buttonLoading.set(false);
}
})
);
columnsDiv.append(...columnElements);

collapsed.addListener(() => {
timeline.redraw();
});

// Now we need to update its width, we can't break lines and anything like that.
headerRight.innerHTML = '';
headerRight.append(columnsDiv);
// And set this width as minimum for the table rendered below.
const visualization = document.getElementById('visualization')!;
dom.update(visualization,
dom.cls('collapsed', collapsed)
);

recalculateHeader();

} finally {
headerMonitor.resume();
}
}

export function recalculateHeader() {

try {
headerMonitor.pause();

const visualization = document.getElementById('visualization')!;
const columnsDiv = document.querySelector('.group-header-columns')! as HTMLElement;
const columnElements = Array.from(columnsDiv.children);

// Now measure each individual line, and provide grid-template-columns variable with minimum
// width to make up for a column and header width.
// grid-template-columns: var(--grid-template-columns, repeat(12, max-content));

// First set the auto width.
columnsDiv.style.setProperty('grid-template-columns',
'auto '.repeat(columnElements.length - 1) + '20px 1fr');

const widths = columnElements.map(part => part.getBoundingClientRect().width);
const templateColumns = widths
.map(w => `minmax(${w}px, max-content)`)
.join(' ');

visualization.style.setProperty('--grid-template-columns', templateColumns);
anchorHeader();

const firstLine = document.querySelector('.group-template');
if (!firstLine) {
console.error('No first line found');
return;
}

const firstLineColumns = Array.from(firstLine.children)
.map(elementWidth)
.map(pixels)
.join(' ');

columnsDiv.style.setProperty('grid-template-columns', firstLineColumns);

const firstPartWidth = Array.from(firstLine.children)[0].getBoundingClientRect().width;

const width = Math.ceil(columnsDiv.getBoundingClientRect().width);
// Set custom property --group-header-width to the width of the groupHeader
visualization.style.setProperty('--group-header-width', `${width}px`);
visualization.style.setProperty('--group-first-width', `${firstPartWidth}px`);


} finally {
headerMonitor.resume();
}
}

export function anchorHeader() {
const store = anchorHeader as any as {lastTop: number;};
store.lastTop = store.lastTop ?? 0;
const panelEl = document.querySelector('.vis-panel.vis-left')!;
const headerEl = document.getElementById('groupHeader')!;
const headerBottomEl = headerEl.querySelector('.bottom')! as HTMLElement;
const headerTopEl = headerEl.querySelector('.top')! as HTMLElement;

const contentEl = panelEl.querySelector('.vis-labelset')!;
const top = Math.ceil(panelEl.getBoundingClientRect().top);
if (top === store.lastTop) {
return;
}
store.lastTop = top;
headerTopEl.style.setProperty('height', `${top - 30}px`);

// Also adjust the left property of the group-header, as it may have a scrool element.
const left = Math.ceil(contentEl.getBoundingClientRect().left);
headerEl.style.setProperty('left', `${left}px`);
}



const elementWidth = (el: Element) => el.getBoundingClientRect().width;
const pixels = (w: number) => `${w}px`;
function debounced(func: (...args: any[]) => void, wait: number = 100): () => void {
let timeout: NodeJS.Timeout | null = null;
return function(...args: any[]) {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func(...args);
}, wait);
};
}
11 changes: 11 additions & 0 deletions timeline/icons.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Create out folder if it doesn't exist
mkdir -p out

# Define array if icons to copy
declare -a icons=("three-dots" "arrows-collapse-vertical" "exclamation-octagon" "chevron-bar-left" "chevron-bar-right" "border-all")

# Copy icons to out folder
for icon in "${icons[@]}"
do
cp node_modules/@shoelace-style/shoelace/dist/assets/icons/$icon.svg out/
done
Loading
Loading