diff --git a/src/consts.ts b/src/consts.ts index 6d64935f..a7272848 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -11,4 +11,6 @@ export const DEFAULT_TEXTURE_RESOLUTION = 10 export const DEFAULT_EDGE_POINTS = 20 export const DEFAULT_EDGE_MINIMUM_BEZIER = 64 export const DEFAULT_HORIZONTAL_SCALE_MULTIPLIER = 0.25 -export const DEFAULT_HORIZONTAL_SCALE = 1 \ No newline at end of file +export const DEFAULT_HORIZONTAL_SCALE = 1 +export const DEFAULT_GUIDES_COUNT = 30 +export const DEFAULT_GUIDES_MIN_GAP = 260 \ No newline at end of file diff --git a/src/factories/guide.ts b/src/factories/guide.ts new file mode 100644 index 00000000..a5f23627 --- /dev/null +++ b/src/factories/guide.ts @@ -0,0 +1,73 @@ +import { Container } from 'pixi.js' +import { rectangleFactory } from '@/factories/rectangle' +import { FormatDate } from '@/models/guides' +import { waitForViewport } from '@/objects' +import { waitForApplication } from '@/objects/application' +import { waitForConfig } from '@/objects/config' +import { waitForCull } from '@/objects/culling' +import { waitForFonts } from '@/objects/fonts' +import { waitForScale } from '@/objects/scale' + +export type GuideFactory = Awaited> + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export async function guideFactory() { + const application = await waitForApplication() + const viewport = await waitForViewport() + const cull = await waitForCull() + const config = await waitForConfig() + const { inter } = await waitForFonts() + + const element = new Container() + cull.add(element) + + const rectangle = await rectangleFactory() + element.addChild(rectangle) + + const label = inter('') + element.addChild(label) + + let currentDate: Date | undefined + let currentLabelFormatter: FormatDate + + application.ticker.add(() => { + updatePosition() + + if (element.height !== application.screen.height) { + renderLine() + } + }) + + async function render(date: Date, labelFormatter: FormatDate): Promise { + currentDate = date + currentLabelFormatter = labelFormatter + + renderLine() + await renderLabel(date) + } + + function renderLine(): void { + rectangle.width = config.styles.guideLineWidth + rectangle.height = application.screen.height + rectangle.tint = config.styles.guideLineColor + } + + function renderLabel(date: Date): void { + label.text = currentLabelFormatter(date) + label.fontSize = config.styles.guideTextSize + label.tint = config.styles.guideTextColor + label.position.set(config.styles.guideTextLeftPadding, config.styles.guideTextTopPadding) + } + + async function updatePosition(): Promise { + const scale = await waitForScale() + if (currentDate !== undefined) { + element.position.x = scale(currentDate) * viewport.scale._x + viewport.worldTransform.tx + } + } + + return { + element, + render, + } +} \ No newline at end of file diff --git a/src/factories/guides.ts b/src/factories/guides.ts new file mode 100644 index 00000000..737c3e44 --- /dev/null +++ b/src/factories/guides.ts @@ -0,0 +1,160 @@ +import { Container } from 'pixi.js' +import { DEFAULT_GUIDES_COUNT, DEFAULT_GUIDES_MIN_GAP } from '@/consts' +import { GuideFactory, guideFactory } from '@/factories/guide' +import { FormatDate } from '@/models/guides' +import { LayoutSettings } from '@/models/layout' +import { waitForViewport } from '@/objects' +import { emitter } from '@/objects/events' +import { waitForScale } from '@/objects/scale' +import { repeat } from '@/utilities/repeat' +import { formatDateFns, labelFormats, timeIncrements } from '@/utilities/timeIncrements' + +const visibleGuideBoundsMargin = 300 + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export async function guidesFactory() { + const viewport = await waitForViewport() + + const element = new Container() + const guides = new Map() + + let paused = false + + let currentIncrement = 0 + let currentAnchor = 0 + let labelFormatter: FormatDate = (date) => date.toLocaleTimeString() + + emitter.on('viewportDateRangeUpdated', () => { + update() + }) + emitter.on('layoutCreated', (layout) => onLayoutUpdate(layout)) + emitter.on('layoutUpdated', (layout) => onLayoutUpdate(layout)) + + function render(): void { + createGuides() + } + + async function createGuides(): Promise { + const guideIndexes = Array.from({ length: DEFAULT_GUIDES_COUNT }, (val, i) => i) + + for await (const guideIndex of guideIndexes) { + await createGuide(guideIndex) + } + } + + async function createGuide(index: number): Promise { + if (guides.has(index)) { + return + } + + const response = await guideFactory() + + element.addChild(response.element) + + guides.set(index, response) + } + + async function update(): Promise { + if (paused) { + return + } + + const scale = await waitForScale() + const left = scale.invert(viewport.left - visibleGuideBoundsMargin) + const gapDate = scale.invert(viewport.left - visibleGuideBoundsMargin + DEFAULT_GUIDES_MIN_GAP / viewport.scale.x) + + if (!(left instanceof Date) || !(gapDate instanceof Date)) { + console.warn('Guides: Attempted to update guides with a non-temporal layout.') + return + } + + const gap = gapDate.getTime() - left.getTime() + const { increment, getAnchor, labelFormat } = timeIncrements.find(timeSlot => timeSlot.ceiling > gap) ?? timeIncrements[0] + + const anchor = getAnchor === undefined + ? Math.floor(left.getTime() / increment) * increment + : getAnchor(left) + + if (increment !== currentIncrement || anchor !== currentAnchor) { + currentIncrement = increment + currentAnchor = anchor + setLabelFormat(labelFormat) + } + + setGuides() + } + + function setLabelFormat(labelFormat: string): void { + switch (labelFormat) { + case labelFormats.minutes: + labelFormatter = formatDateFns.timeByMinutesWithDates + break + case labelFormats.date: + labelFormatter = formatDateFns.date + break + default: + labelFormatter = formatDateFns.timeBySeconds + } + } + + function setGuides(): void { + const times = getGuideTimes() + const guidesStore = new Map(guides.entries()) + const unused = Array.from(guidesStore.keys()).filter((time) => { + return !times.includes(time) + }) + + guides.clear() + + for (const time of times) { + if (guidesStore.has(time)) { + const guide = guidesStore.get(time)! + + guides.set(time, guide) + + continue + } + + const guide = guidesStore.get(unused.pop() ?? -1) + + if (guide === undefined) { + console.warn('Guides: No unused guides available to render.') + continue + } + + guide.render(new Date(time), labelFormatter) + guides.set(time, guide) + } + } + + function getGuideTimes(): number[] { + return repeat(DEFAULT_GUIDES_COUNT, (index) => { + return currentAnchor + currentIncrement * index + }) + } + + function onLayoutUpdate(layout: LayoutSettings): void { + if (!layout.isTrace()) { + pauseGuides() + return + } + + resumeGuides() + } + + function pauseGuides(): void { + paused = true + element.visible = false + } + + function resumeGuides(): void { + paused = false + update() + element.visible = true + } + + return { + element, + render, + } +} \ No newline at end of file diff --git a/src/models/RunGraph.ts b/src/models/RunGraph.ts index 3ab9afae..90447116 100644 --- a/src/models/RunGraph.ts +++ b/src/models/RunGraph.ts @@ -58,6 +58,12 @@ export type RunGraphStyles = { nodeToggleBorderColor?: ColorSource, nodeSelectedBorderColor?: ColorSource, edgeColor?: string, + guideLineWidth?: number, + guideLineColor?: string, + guideTextTopPadding?: number, + guideTextLeftPadding?: number, + guideTextSize?: number, + guideTextColor?: string, node?: (node: RunGraphNode) => RunGraphNodeStyles, } diff --git a/src/models/guides.ts b/src/models/guides.ts new file mode 100644 index 00000000..3cb9fdd1 --- /dev/null +++ b/src/models/guides.ts @@ -0,0 +1,7 @@ +export type FormatDate = (date: Date) => string + +export type FormatDateFns = { + timeBySeconds: FormatDate, + timeByMinutesWithDates: FormatDate, + date: FormatDate, +} \ No newline at end of file diff --git a/src/objects/application.ts b/src/objects/application.ts index 37a7de0e..350d4c6f 100644 --- a/src/objects/application.ts +++ b/src/objects/application.ts @@ -32,6 +32,9 @@ function createApplication(stage: HTMLDivElement): void { resolution: window.devicePixelRatio, }) + // for setting the viewport above the guides + application.stage.sortableChildren = true + stage.appendChild(application.view as HTMLCanvasElement) emitter.emit('applicationCreated', application) diff --git a/src/objects/config.ts b/src/objects/config.ts index dec26358..153d9fee 100644 --- a/src/objects/config.ts +++ b/src/objects/config.ts @@ -21,6 +21,12 @@ const defaults: Omit = { nodeToggleBorderColor: '#51525C', nodeSelectedBorderColor: 'rgba(104, 125, 155, 0.4)', edgeColor: '#51525C', + guideLineWidth: 1, + guideLineColor: '#51525C', + guideTextTopPadding: 8, + guideTextLeftPadding: 8, + guideTextSize: 12, + guideTextColor: '#ADADAD', node: () => ({ background: '#ffffff', }), diff --git a/src/objects/guides.ts b/src/objects/guides.ts new file mode 100644 index 00000000..19ac7c69 --- /dev/null +++ b/src/objects/guides.ts @@ -0,0 +1,15 @@ +import { guidesFactory } from '@/factories/guides' +import { waitForApplication } from '@/objects/application' + +export async function startGuides(): Promise { + const application = await waitForApplication() + const { element, render } = await guidesFactory() + + application.stage.addChild(element) + + render() +} + +export function stopGuides(): void { + // nothing to stop +} \ No newline at end of file diff --git a/src/objects/index.ts b/src/objects/index.ts index 7c9c47cf..8b66dce5 100644 --- a/src/objects/index.ts +++ b/src/objects/index.ts @@ -5,6 +5,7 @@ import { startCulling, stopCulling } from '@/objects/culling' import { startEdgeCulling, stopEdgeCulling } from '@/objects/edgeCulling' import { emitter } from '@/objects/events' import { startFonts, stopFonts } from '@/objects/fonts' +import { startGuides, stopGuides } from '@/objects/guides' import { startLabelCulling, stopLabelCulling } from '@/objects/labelCulling' import { startNodes, stopNodes } from '@/objects/nodes' import { startScale, stopScale } from '@/objects/scale' @@ -27,6 +28,7 @@ export function start({ stage, props }: StartParameters): void { startApplication() startViewport(props) startScale() + startGuides() startNodes() startScope() startFonts() @@ -45,6 +47,7 @@ export function stop(): void { stopApplication() stopViewport() stopScale() + stopGuides() stopStage() stopNodes() stopConfig() diff --git a/src/objects/viewport.ts b/src/objects/viewport.ts index d2e7ff60..3ec2e6ce 100644 --- a/src/objects/viewport.ts +++ b/src/objects/viewport.ts @@ -26,6 +26,9 @@ export async function startViewport(props: RunGraphProps): Promise { passiveWheel: false, }) + // ensures the viewport is above the guides + viewport.zIndex = 1 + viewport .drag() .pinch() diff --git a/src/utilities/timeIncrements.ts b/src/utilities/timeIncrements.ts new file mode 100644 index 00000000..f6da6dd7 --- /dev/null +++ b/src/utilities/timeIncrements.ts @@ -0,0 +1,132 @@ +import { FormatDateFns } from '@/models/guides' + +function formatDateBySeconds(date: Date): string { + return date.toLocaleTimeString() +} +function formatDateByMinutes(date: Date): string { + const currentLocale = navigator.language + return new Intl.DateTimeFormat(currentLocale, { timeStyle: 'short' }).format(date) +} +function formatDate(date: Date): string { + const currentLocale = navigator.language + return new Intl.DateTimeFormat(currentLocale, { dateStyle: 'short' }).format(date) +} + +function formatByMinutesWithDates(date: Date): string { + if (date.getHours() === 0 && date.getMinutes() === 0) { + return `${formatDateFns.date(date)}\n${formatDateByMinutes(date)}` + } + + return formatDateByMinutes(date) +} + +export const formatDateFns: FormatDateFns = { + timeBySeconds: formatDateBySeconds, + timeByMinutesWithDates: formatByMinutesWithDates, + date: formatDate, +} + +export const labelFormats = { + seconds: 'seconds', + minutes: 'minutes', + date: 'date', +} + +const getRoundedAnchor = { + day: (date: Date): number => { + const dateCopy = new Date(date.getTime()) + dateCopy.setHours(0, 0, 0, 0) + return dateCopy.getTime() + }, + week: (date: Date): number => { + const dateCopy = new Date(date.getTime()) + dateCopy.setHours(0, 0, 0, 0) + dateCopy.setDate(date.getDate() - date.getDay() + 1) + return dateCopy.getTime() + }, +} + +export const timeLengths = { + second: 1000, + minute: 1000 * 60, + hour: 1000 * 60 * 60, + day: 1000 * 60 * 60 * 24, + week: 1000 * 60 * 60 * 24 * 7, +} + +export const timeIncrements = [ + { + ceiling: timeLengths.second * 4, + increment: timeLengths.second, + labelFormat: labelFormats.seconds, + }, { + ceiling: timeLengths.second * 8, + increment: timeLengths.second * 5, + labelFormat: labelFormats.seconds, + }, { + ceiling: timeLengths.second * 13, + increment: timeLengths.second * 10, + labelFormat: labelFormats.seconds, + }, { + ceiling: timeLengths.second * 20, + increment: timeLengths.second * 15, + labelFormat: labelFormats.seconds, + }, { + ceiling: timeLengths.second * 45, + increment: timeLengths.second * 30, + labelFormat: labelFormats.seconds, + }, { + ceiling: timeLengths.minute * 4, + increment: timeLengths.minute, + labelFormat: labelFormats.minutes, + }, { + ceiling: timeLengths.minute * 8, + increment: timeLengths.minute * 5, + labelFormat: labelFormats.minutes, + }, { + ceiling: timeLengths.minute * 13, + increment: timeLengths.minute * 10, + labelFormat: labelFormats.minutes, + }, { + ceiling: timeLengths.minute * 28, + increment: timeLengths.minute * 15, + labelFormat: labelFormats.minutes, + }, { + ceiling: timeLengths.hour * 1.24, + increment: timeLengths.minute * 30, + labelFormat: labelFormats.minutes, + }, { + ceiling: timeLengths.hour * 3, + increment: timeLengths.hour, + labelFormat: labelFormats.minutes, + }, { + ceiling: timeLengths.hour * 8, + increment: timeLengths.hour * 2, + getAnchor: getRoundedAnchor.day, + labelFormat: labelFormats.minutes, + }, { + ceiling: timeLengths.hour * 13, + increment: timeLengths.hour * 6, + getAnchor: getRoundedAnchor.day, + labelFormat: labelFormats.minutes, + }, { + ceiling: timeLengths.hour * 22, + increment: timeLengths.hour * 12, + getAnchor: getRoundedAnchor.day, + labelFormat: labelFormats.minutes, + }, { + ceiling: timeLengths.day * 4, + increment: timeLengths.day, + getAnchor: getRoundedAnchor.day, + labelFormat: labelFormats.date, + }, { + ceiling: timeLengths.week * 2, + increment: timeLengths.week, + getAnchor: getRoundedAnchor.week, + labelFormat: labelFormats.date, + }, { + ceiling: Infinity, + increment: timeLengths.week * 4, + labelFormat: labelFormats.date, + }, +] \ No newline at end of file