feat: added cool graph

This commit is contained in:
Andre Henriques 2025-04-07 11:16:10 +01:00
parent e80a13b1d8
commit 8a38d407c4
8 changed files with 1054 additions and 432 deletions

View File

@ -0,0 +1,232 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type FuncBase = (n: number) => number;
let {
grid = true,
grid_size = 35,
grid_scale_size = 1,
show_controll_buttons = false,
controll_buttons,
children,
onmousedown: ext_onmousedown,
onmouseup: ext_onmouseup,
onmouseleave: ext_onmouseleave,
onmousemove: ext_onmousemove
}: {
children?: Snippet<[FuncBase, FuncBase, FuncBase, FuncBase]>;
grid?: boolean;
grid_size?: number;
grid_scale_size?: number;
controll_buttons?: Snippet;
show_controll_buttons?: boolean;
onmousedown?: (e: MouseEvent) => void;
onmouseup?: (e: MouseEvent) => void;
onmouseleave?: (e: MouseEvent) => void;
onmousemove?: (e: MouseEvent) => void;
} = $props();
//
// Display stuff
//
// The canvas is not really a canvas but a div will kinda act like a canvas
let canvas: HTMLDivElement | undefined = $state();
//
// Position + movement + interation
//
//
let y = $state(0);
let x = $state(0);
let initialGridOffsetX = $state(0);
let initialGridOffsetY = $state(0);
$effect(() => {
let box = canvas?.getBoundingClientRect();
if (!box) return;
let w = box.width;
let h = box.height;
initialGridOffsetX = Math.round(w / 2 - Math.round(w / 2 / grid_size) * grid_size);
initialGridOffsetY = Math.round(h / 2 - Math.floor(h / 2 / grid_size) * grid_size);
});
// Holdes the position for pan and select
let startMX = $state(0);
let startMY = $state(0);
let startX = $state(0);
let startY = $state(0);
let isDragging = $state(false);
//
// Mouse interation
//
// Right click and drag to move around
function onmousedown(e: MouseEvent) {
ext_onmousedown?.(e);
if (e.defaultPrevented) {
return;
}
let box = canvas?.getBoundingClientRect();
const mouseX = e.x - (box?.left ?? 0);
const mouseY = e.y - (box?.top ?? 0);
startMX = mouseX;
startMY = mouseY;
if (e.button === 2 || e.button === 1) {
isDragging = true;
startX = x;
startY = y;
}
e.preventDefault();
e.stopPropagation();
}
async function onmouseup(e: MouseEvent) {
ext_onmouseup?.(e);
if (e.defaultPrevented) return;
if (isDragging) {
isDragging = false;
startX = 0;
startY = 0;
startMX = 0;
startMY = 0;
}
}
// if the mouse leaves the area clear all the stuffs
function onmouseleave(e: MouseEvent) {
ext_onmouseleave?.(e);
if (e.defaultPrevented) return;
}
function onmousemove(e: MouseEvent) {
ext_onmousemove?.(e);
if (e.defaultPrevented) return;
if (isDragging) {
let box = canvas?.getBoundingClientRect();
let mouseX = e.x - (box?.left ?? 0);
let mouseY = e.y - (box?.top ?? 0);
x = startX + (startMX - mouseX);
y = startY + (startMY - mouseY);
}
}
/*
* For now disable the right click menu
*/
function oncontextmenu(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
}
//
//
// Utils
//
//
function canvasX(inX: number) {
let box = canvas?.getBoundingClientRect();
return inX * grid_size + Math.round((box?.width ?? 0) / 2) - x;
}
function canvasY(inY: number) {
let box = canvas?.getBoundingClientRect();
return Math.round((box?.height ?? 0) / 2) - y - inY * grid_size;
}
function worldX(canvasX: number) {
let box = canvas?.getBoundingClientRect();
return (canvasX + x - Math.round((box?.width ?? 0) / 2)) / grid_size;
}
function worldY(canvasY: number) {
let box = canvas?.getBoundingClientRect();
return (Math.round((box?.height ?? 0) / 2) - y - canvasY) / grid_size;
}
</script>
<div class="w-full flex-grow p-5 px-7">
<div
class="bg-white rounded-lg w-full h-full grid relative overflow-hidden"
style="--grid-size: {grid_size * grid_scale_size}px;"
role="none"
bind:this={canvas}
{onmousedown}
{onmouseup}
{oncontextmenu}
{onmousemove}
{onmouseleave}
>
{#if grid}
<!-- Background grid -->
<!-- x -->
<div class="absolute background" style="--offset-y: {-y + initialGridOffsetY}px;"></div>
<!-- y -->
<div
class="absolute background"
style="--grid-angle: 90deg; --offset-x: {-x + initialGridOffsetX}px;"
></div>
{/if}
{#if show_controll_buttons}
<!-- Control buttons -->
<div class="absolute left-5 top-5 flex gap-5 z-50">
<!-- Reset view port -->
{#if x !== 0 || y !== 0}
<button
class="bg-white p-2 shadow rounded-lg shadow-violet-500 h-[40px] w-[40px] text-violet-500"
onclick={() => {
x = 0;
y = 0;
}}
>
<span class="bi bi-arrow-clockwise"></span>
</button>
{/if}
</div>
{#if controll_buttons}
{@render controll_buttons()}
{/if}
{/if}
{#if children}
{@render children(canvasX, canvasY, worldX, worldY)}
{/if}
</div>
</div>
<style>
:root {
--grid-color: #c1c1c1;
--grid-size: 35px;
--border-size: 1px;
--grid-angle: 0deg;
--offset-x: 0px;
--offset-y: 0px;
}
.background {
top: 0;
left: 0;
right: -1px;
bottom: -1px;
background: repeating-linear-gradient(
var(--grid-angle),
transparent,
transparent calc(var(--grid-size) - var(--border-size)),
var(--grid-color) calc(var(--grid-size) - var(--border-size)),
var(--grid-color) var(--grid-size)
);
background-position: var(--offset-x) var(--offset-y);
background-size: var(--grid-size) var(--grid-size);
}
</style>

View File

@ -0,0 +1,29 @@
export interface Point {
x: number;
y: number;
}
export interface Rect extends Point {
width: number;
height: number;
}
export function extractLinkNodePosX(canvasX: (x: number) => number, rect: Rect, grid_size: number) {
if (rect.x === -1) {
return canvasX(rect.x);
}
if (rect.x === rect.width) {
return canvasX(rect.x) + rect.width * grid_size;
}
return canvasX(rect.x) + rect.x * grid_size + grid_size / 2;
}
export function extractLinkNodePosY(canvasY: (x: number) => number, node: Rect, grid_size: number) {
if (node.y === -1) {
return canvasY(node.y);
}
if (node.y === node.height) {
return canvasY(node.y) + node.height * grid_size;
}
return canvasY(node.y) + node.y * grid_size + grid_size / 2;
}

View File

@ -47,7 +47,21 @@ function createStatusStore() {
nodesR[null as any] = {
icon: 'plus',
id: null as any,
name: 'Created'
name: 'Created',
x: -4,
y: 10,
width: 8,
height: 4
} as any;
nodesR['null'] = {
icon: 'plus',
id: null as any,
name: 'Created',
x: -4,
y: 10,
width: 8,
height: 4
} as any;
for (const nodeId of nodesId) {

View File

@ -9,7 +9,8 @@
import PayRange from './PayRange.svelte';
import LineGraphs, { type LineGraphData } from './LineGraph.svelte';
import * as d3 from 'd3';
import { countReducer } from './utils';
import { countReducer, type EventsStat } from './utils';
import CoolGraph from './CoolGraph/CoolGraph.svelte';
onMount(() => {
applicationStore.loadAll();
@ -22,6 +23,8 @@
let viewGraph: { xs: number[]; ys: number[] } = $state({ xs: [], ys: [] });
let statusGraph: LineGraphData = $state([]);
let events: EventsStat[] = $state([]);
let width: number = $state(300);
onMount(async () => {
@ -37,19 +40,7 @@
})();
(async () => {
type EventsStat = {
// application_id
a: string;
// created time
c: number;
// id
i: string;
// new_status_id
s?: string;
// Type
t: number;
};
const events: EventsStat[] = await get('events/');
events = await get('events/');
const pre_created_graph = events
.filter((a) => a.t === 0)
@ -125,16 +116,12 @@
data={[...statusStore.nodes, 'Created' as const].reduce(
(acc, item) => {
if (item === 'Created') {
const count = applicationStore.all.filter(
(a) => a.status_id === null
).length;
const count = applicationStore.all.filter((a) => a.status_id === null).length;
if (count === 0) return acc;
acc[item] = count;
return acc;
}
const count = applicationStore.all.filter(
(a) => item.id === a.status_id
).length;
const count = applicationStore.all.filter((a) => item.id === a.status_id).length;
if (count === 0) return acc;
acc[item.name] = count;
return acc;
@ -237,8 +224,7 @@
sensitivity={0.01}
data={applicationStore.all.reduce(
(acc, item) => {
const l =
item.inperson_type === '' ? 'Unknown' : item.inperson_type;
const l = item.inperson_type === '' ? 'Unknown' : item.inperson_type;
if (acc[l]) {
acc[l] += 1;
} else {
@ -326,9 +312,7 @@
.replace(/[^\d\-]/g, '')
.replace(//g, '-')
.split('-');
return (
Number(payrange[0]) + Number(payrange[1] ?? payrange[0])
);
return Number(payrange[0]) + Number(payrange[1] ?? payrange[0]);
})
.reduce((acc, a) => acc + a, 0) /
(fapps.length * 2)
@ -406,11 +390,7 @@
.replace(/[^\d\-]/g, '')
.replace(//g, '-')
.split('-');
return (
(Number(payrange[0]) +
Number(payrange[1] ?? payrange[0])) /
2
);
return (Number(payrange[0]) + Number(payrange[1] ?? payrange[0])) / 2;
})
.reduce(
(acc, a) => {
@ -432,6 +412,8 @@
</div>
{/each}
</div>
<h1>Cool graph</h1>
<CoolGraph {events} />
</div>
</div>
</div>

View File

@ -0,0 +1,196 @@
<script lang="ts">
import { statusStore } from '$lib/Types.svelte';
import FlowArea from '../../../components/FlowArea.svelte';
import type { PrevType } from './types';
import { type EventsStat } from '../utils';
import CoolGraphNode from './CoolGraphNode.svelte';
let {
events
}: {
events: EventsStat[];
} = $props();
// Remove view events
const internalEvs = $derived(events.filter((a) => a.t !== 2).toSorted((a, b) => a.c - b.c));
let depth = $state(0);
let applicationsTimelines = $derived.by(() => {
let appState: Record<string, EventsStat[]> = {};
for (const ev of internalEvs) {
if (appState[ev.a] !== undefined) {
if (ev.t == 0) continue;
appState[ev.a].push(ev);
} else {
if (ev.t == 1) continue;
appState[ev.a] = [ev];
}
}
return Object.values(appState);
});
let figHeight = $state(0);
let figWidth = $state(0);
type Pos = {
x: number;
y: number;
count: number;
from: PrevType;
};
const posByDepth = $derived.by(() => {
if (applicationsTimelines.length === 0) return [];
const levels: Record<string, Pos>[] = [];
let timelines = [...applicationsTimelines];
while (timelines.length > 0) {
const s: Record<string, Pos> = {};
const nextTurnTimeLines: typeof timelines = [];
const thisX = levels.length;
// Deepclone prev
if (thisX > 0) {
for (const e of Object.keys(levels[thisX - 1])) {
s[e] = {
...levels[thisX - 1][e],
from: {
prev: levels[thisX - 1][e].from,
cur: {}
}
};
}
}
const dependencies: Record<string, string[]> = {};
const thisLine = new Set<string>();
for (const t of timelines) {
const cur = t[thisX];
// Only allow create as the first event of the timeline
if ((cur.t === 0 && thisX !== 0) || (cur.t === 1 && thisX === 0)) {
continue;
}
const st = cur.s ?? 'null';
thisLine.add(st);
let prev: string | undefined = undefined;
if (thisX > 0) {
prev = t[thisX - 1].s ?? 'null';
if (dependencies[st]) {
dependencies[st].push(prev);
} else {
dependencies[st] = [prev];
}
}
if (s[st]) {
s[st].count += 1;
s[st].x = thisX;
if (prev) {
if (s[st].from.cur[prev]) {
s[st].from.cur[prev] += 1;
} else {
s[st].from.cur[prev] = 1;
}
}
} else {
s[st] = {
x: thisX,
y: 0,
from: { cur: {} },
count: 1
};
if (prev) s[st].from.cur[prev] = 1;
}
if (t.length - 1 !== thisX) {
nextTurnTimeLines.push(t);
}
}
outer: for (let i = 0; i < thisX + 1; i++) {
for (const v of Object.values(s)) {
if (v.x === i) {
continue outer;
}
}
for (const v of Object.values(s)) {
if (v.x > i) {
v.x -= 1;
}
}
}
const max = Object.values(s).reduce((acc, a) => Math.max(acc, a.x), 0);
for (const tName of Object.keys(s)) {
const t = s[tName];
if (t.x != max) continue;
const thisLineDeps = dependencies[tName]?.filter((d) => thisLine.has(d)) ?? [];
if (thisLineDeps.length == 0) continue;
// If no cross dependencies
if (thisLineDeps.every((d) => !dependencies[d].includes(tName))) {
t.x += 1;
thisLine.delete(tName);
}
}
// TODO look for deps in the same line
const findY: Record<number, number> = {};
for (const target of Object.keys(s)) {
if (findY[s[target].x] === undefined) {
s[target].y = 0;
findY[s[target].x] = 1;
} else {
s[target].y = findY[s[target].x];
findY[s[target].x] += 1;
}
}
levels.push(s);
timelines = nextTurnTimeLines;
}
return levels;
});
const levelSize = $derived(Math.floor(figWidth / posByDepth.length));
</script>
{#if statusStore.nodesR && posByDepth.length > 0}
<div>
<div class="flex">
<span>{depth}</span>
<input
class="flex-grow"
min="0"
max={posByDepth.length - 1}
type="range"
step="1"
bind:value={depth}
/>
</div>
<div class="flex flex-col h-[1200px]">
<FlowArea grid_size={25 / 2} grid_scale_size={2}>
{#snippet children(canvasX, canvasY)}
{#each Object.keys(posByDepth[depth]) as nodeId}
{@const cur = posByDepth[depth][nodeId]}
{@const node = statusStore.nodesR[nodeId === 'null' ? (null as any) : nodeId]}
{#if node}
<CoolGraphNode {canvasX} {canvasY} {node} from={cur.from} count={cur.count} />
{/if}
{/each}
{/snippet}
</FlowArea>
</div>
</div>
{/if}

View File

@ -0,0 +1,182 @@
<script lang="ts">
import { statusStore } from '$lib/Types.svelte';
import type { Node } from '../../flow/types';
import type { PrevType } from './types';
type FuncBase = (x: number) => number;
let {
node,
canvasX,
canvasY,
count,
from
}: {
node: Node;
canvasX: FuncBase;
canvasY: FuncBase;
from: PrevType;
count: number;
} = $props();
let fromText = $derived(
Object.keys(from.cur).reduce(
(acc, i) =>
`${acc}\n\t${i === 'null' ? 'Created' : statusStore.nodesR[i].name}: ${from.cur[i]}`,
'From:'
)
);
type Particle = {
to: string;
percentage: number;
rand: number;
randX: number;
randY: number;
color: string;
};
let particles: Particle[] = $state([]);
let particlesState: Record<string, number> = $state({});
let MAX_PARTICLES = 200;
function spwanParticle(key: string) {
if (particles.length > MAX_PARTICLES) return;
if (from.cur[key] <= particlesState[key]) {
return;
}
particles.push({
percentage: 0,
to: key,
rand: Math.random(),
randX: Math.random(),
randY: Math.random(),
color: [
'oklch(68.5% 0.169 237.323)',
'oklch(62.3% 0.214 259.815)',
'oklch(62.7% 0.265 303.9)'
][Math.floor(Math.random() * 3)]
});
particlesState[key] = (particlesState[key] ?? 0) + 1;
}
const MAX_ROUNDS = 40;
let timeout: number | undefined = undefined;
function t(k: number) {
if (k > MAX_ROUNDS || !from) {
timeout = undefined;
return;
}
for (const key of Object.keys(from.cur)) {
spwanParticle(key);
}
timeout = setTimeout(() => t(k + 1), Math.random() * 1500) as unknown as number;
}
$effect(() => {
if (timeout !== undefined) clearTimeout(timeout);
const _ = from;
particles = [];
timeout = setTimeout(() => t(0), 10) as unknown as number;
return () => {
if (timeout !== undefined) clearTimeout(timeout);
};
});
</script>
<div
class="shadow-sm shadow-violet-500 border rounded-full min-w-[50px] min-h-[50px] grid place-items-center bg-white z-40 absolute"
style="left: {canvasX(node.x + node.width / 2)}px; top: {canvasY(node.y - node.height / 2)}px;"
title={`${node?.name ?? ''}: ${count}\n${fromText}`}
>
<span class={`bi bi-${node?.icon ?? ''}`}> </span>
</div>
{#each particles as particle}
{@const to = statusStore.nodesR[particle.to]}
{@const f_x = to.x + to.width / 2 + particle.randX}
{@const f_y = to.y - to.height / 2 + particle.randY}
{@const x = (p: number) => f_x + (node.x + node.width / 2 - f_x) * p}
{@const y = (p: number) => f_y + (node.y - node.height / 2 - f_y) * p}
<div
class="animateMove absolute w-2 h-2 text-black rounded-full shadow-lg z-20"
style="--start-x: {canvasX(x(0)) + 25}px; --start-y: {canvasY(y(0)) + 25}px; --end-x: {canvasX(
x(1)
) + 25}px; --end-y: {canvasY(y(1)) + 25}px; background: {particle.color};"
></div>
{/each}
{#each Object.keys(from.cur) as link}
{@const size = 20}
{@const to = statusStore.nodesR[link]}
{@const f_x = node.x + node.width / 2}
{@const f_y = node.y - node.height / 2}
{@const t_x = to.x + to.width / 2}
{@const t_y = to.y - to.height / 2}
{@const diff = Math.sqrt((f_x - t_x) ** 2 + (f_y - t_y) ** 2)}
{@const theta = Math.atan2(f_x - t_x, f_y - t_y)}
<div
class="absolute bg-gradient-to-t from-blue-400/70 to-blue-200/70 z-10"
style="height: {(25 / 2) *
diff}px; width: {size}px; transform-origin: top center; transform: translate({canvasX(f_x) +
25 -
size / 2}px, {canvasY(f_y) + 25 + size / 8}px) rotate({theta}rad);"
></div>
{/each}
{#if from.prev}
{#each Object.keys(from.prev.cur) as link}
{@const size = 10}
{@const to = statusStore.nodesR[link]}
{@const f_x = node.x + node.width / 2}
{@const f_y = node.y - node.height / 2}
{@const t_x = to.x + to.width / 2}
{@const t_y = to.y - to.height / 2}
{@const diff = Math.sqrt((f_x - t_x) ** 2 + (f_y - t_y) ** 2)}
{@const theta = Math.atan2(f_x - t_x, f_y - t_y)}
<div
class="absolute bg-gradient-to-t from-violet-400/70 to-violet-200/70"
style="height: {(25 / 2) *
diff}px; width: {size}px; transform-origin: top center; transform: translate({canvasX(f_x) +
25 -
size / 2}px, {canvasY(f_y) + 25 - size / 4}px) rotate({theta}rad);"
></div>
{/each}
{/if}
{#if from.prev && from.prev.prev}
{#each Object.keys(from.prev.prev.cur) as link}
{@const size = 5}
{@const to = statusStore.nodesR[link]}
{@const f_x = node.x + node.width / 2}
{@const f_y = node.y - node.height / 2}
{@const t_x = to.x + to.width / 2}
{@const t_y = to.y - to.height / 2}
{@const diff = Math.sqrt((f_x - t_x) ** 2 + (f_y - t_y) ** 2)}
{@const theta = Math.atan2(f_x - t_x, f_y - t_y)}
<div
class="absolute bg-gradient-to-t from-violet-400/50 to-violet-200/50"
style="height: {(25 / 2) *
diff}px; width: {size}px; transform-origin: top center; transform: translate({canvasX(f_x) +
25 -
size / 2}px, {canvasY(f_y) + 25 - size / 4}px) rotate({theta}rad);"
></div>
{/each}
{/if}
<style>
.animateMove {
animation: animateMoves 5s ease-in-out infinite;
}
@keyframes animateMoves {
0% {
top: var(--start-y);
left: var(--start-x);
}
100% {
top: var(--end-y);
left: var(--end-x);
}
}
</style>

View File

@ -0,0 +1 @@
export type PrevType = { prev?: PrevType; cur: Record<string, number> };

View File

@ -1,489 +1,475 @@
export type EventsStat = {
/** application_id */
a: string;
// created time
c: number;
// id
i: string;
// new_status_id
s?: string;
/** Type */
t: number;
};
export type Sides = 'left' | 'right' | 'bot' | 'top';
export type AxisProps = {
title?: string;
titleFontSize?: number;
titlePos?: Sides;
title?: string;
titleFontSize?: number;
titlePos?: Sides;
};
export class Axis {
private _title?: string;
private _titleFontSize: number;
private _titlePos: Sides;
private _title?: string;
private _titleFontSize: number;
private _titlePos: Sides;
constructor({ title, titleFontSize, titlePos }: AxisProps = {}) {
this._title = title;
this._titleFontSize = titleFontSize ?? 15;
this._titlePos = titlePos ?? 'left';
}
constructor({ title, titleFontSize, titlePos }: AxisProps = {}) {
this._title = title;
this._titleFontSize = titleFontSize ?? 15;
this._titlePos = titlePos ?? 'left';
}
getPadding() {
if (!this._title) return 0;
return this._titleFontSize + 4;
}
getPadding() {
if (!this._title) return 0;
return this._titleFontSize + 4;
}
apply_title(
svg: d3.Selection<KnownAny, KnownAny, null, KnownAny>,
height: number,
width: number,
padding: number
) {
if (!this._title) return undefined;
if (this._titlePos === 'left') {
return (
svg
.append('text')
.attr('text-anchor', 'middle')
.attr('transform', 'rotate(-90)')
.attr('font-size', `${this._titleFontSize}px`)
.attr('font-family', 'Open sans')
// y becomes x
.attr('x', -height / 2)
.attr('y', -padding + this._titleFontSize + 4)
.text(this._title)
);
}
if (this._titlePos === 'right') {
return (
svg
.append('text')
.attr('text-anchor', 'middle')
.attr('transform', 'rotate(90)')
.attr('font-size', `${this._titleFontSize}px`)
.attr('font-family', 'Open sans')
// y becomes x
.attr('x', height / 2)
.attr('y', -width - padding + this._titleFontSize + 4)
.text(this._title)
);
}
if (this._titlePos === 'bot') {
return svg
.append('text')
.attr('text-anchor', 'middle')
.attr('font-size', `${this._titleFontSize}px`)
.attr('font-family', 'Open sans')
.attr('x', width / 2)
.attr('y', height + padding - 4)
.text(this._title);
}
if (this._titlePos === 'top') {
return svg
.append('text')
.attr('text-anchor', 'middle')
.attr('font-size', `${this._titleFontSize}px`)
.attr('font-family', 'Open sans')
.attr('x', width / 2)
.attr('y', -padding + this._titleFontSize)
.text(this._title);
}
apply_title(
svg: d3.Selection<KnownAny, KnownAny, null, KnownAny>,
height: number,
width: number,
padding: number
) {
if (!this._title) return undefined;
if (this._titlePos === 'left') {
return (
svg
.append('text')
.attr('text-anchor', 'middle')
.attr('transform', 'rotate(-90)')
.attr('font-size', `${this._titleFontSize}px`)
.attr('font-family', 'Open sans')
// y becomes x
.attr('x', -height / 2)
.attr('y', -padding + this._titleFontSize + 4)
.text(this._title)
);
}
if (this._titlePos === 'right') {
return (
svg
.append('text')
.attr('text-anchor', 'middle')
.attr('transform', 'rotate(90)')
.attr('font-size', `${this._titleFontSize}px`)
.attr('font-family', 'Open sans')
// y becomes x
.attr('x', height / 2)
.attr('y', -width - padding + this._titleFontSize + 4)
.text(this._title)
);
}
if (this._titlePos === 'bot') {
return svg
.append('text')
.attr('text-anchor', 'middle')
.attr('font-size', `${this._titleFontSize}px`)
.attr('font-family', 'Open sans')
.attr('x', width / 2)
.attr('y', height + padding - 4)
.text(this._title);
}
if (this._titlePos === 'top') {
return svg
.append('text')
.attr('text-anchor', 'middle')
.attr('font-size', `${this._titleFontSize}px`)
.attr('font-family', 'Open sans')
.attr('x', width / 2)
.attr('y', -padding + this._titleFontSize)
.text(this._title);
}
console.error('Unknown title pos', this.titlePos);
return undefined;
}
console.error('Unknown title pos', this.titlePos);
return undefined;
}
//
// Builder pattern functions
//
title(title?: string) {
this._title = title;
return this;
}
//
// Builder pattern functions
//
title(title?: string) {
this._title = title;
return this;
}
titlePos(pos: Sides) {
this._titlePos = pos;
return this;
}
titlePos(pos: Sides) {
this._titlePos = pos;
return this;
}
titleFontSize(size: number) {
this._titleFontSize = size;
return this;
}
titleFontSize(size: number) {
this._titleFontSize = size;
return this;
}
enforcePos(pos: 'vert' | 'hoz', defaultPos: Sides) {
if (pos === 'vert') {
if (this._titlePos !== 'top' && this._titlePos !== 'bot') {
return this.titlePos(defaultPos);
}
} else {
if (this._titlePos !== 'left' && this._titlePos !== 'right') {
return this.titlePos(defaultPos);
}
}
return this;
}
enforcePos(pos: 'vert' | 'hoz', defaultPos: Sides) {
if (pos === 'vert') {
if (this._titlePos !== 'top' && this._titlePos !== 'bot') {
return this.titlePos(defaultPos);
}
} else {
if (this._titlePos !== 'left' && this._titlePos !== 'right') {
return this.titlePos(defaultPos);
}
}
return this;
}
}
export type EnforceSizeType = [number, number];
export function enforceSizeHelper<
H extends Record<NamesH, number>,
NamesH extends string = ''
>(width: number, height: number, enforce: boolean): EnforceHelper<NamesH, H> {
const c = document.createElement('canvas');
const ctx = c.getContext('2d');
if (!ctx) throw new Error('Failed to get ctx');
export function enforceSizeHelper<H extends Record<NamesH, number>, NamesH extends string = ''>(
width: number,
height: number,
enforce: boolean
): EnforceHelper<NamesH, H> {
const c = document.createElement('canvas');
const ctx = c.getContext('2d');
if (!ctx) throw new Error('Failed to get ctx');
const r = {
_width: width,
_height: height,
_enforce: enforce,
_w_p: 0,
const r = {
_width: width,
_height: height,
_enforce: enforce,
_w_p: 0,
_h_indicators: [] as Indicator<NamesH>[]
} as EnforceHelper<NamesH, H>;
_h_indicators: [] as Indicator<NamesH>[]
} as EnforceHelper<NamesH, H>;
r.h = (name, h, type, data, font) => {
const nr = r as EnforceHelper<
NamesH | typeof name,
Record<NamesH | typeof name, number>
>;
r.h = (name, h, type, data, font) => {
const nr = r as EnforceHelper<NamesH | typeof name, Record<NamesH | typeof name, number>>;
// TODO maybe update the values
if (nr[name]) {
return r;
}
// TODO maybe update the values
if (nr[name]) {
return r;
}
let size = 0;
if (typeof h === 'string') {
if (h.endsWith('rem')) {
const rem = Number(h.substring(0, h.length - 3));
if (Number.isNaN(rem)) {
throw new Error('h is not a valid rem value');
}
size =
rem *
Number.parseFloat(
getComputedStyle(document.documentElement).fontSize
);
} else {
const n = Number(h);
if (Number.isNaN(n)) {
throw new Error('h is not a number');
}
size = n;
}
} else {
size = h;
}
let size = 0;
if (typeof h === 'string') {
if (h.endsWith('rem')) {
const rem = Number(h.substring(0, h.length - 3));
if (Number.isNaN(rem)) {
throw new Error('h is not a valid rem value');
}
size = rem * Number.parseFloat(getComputedStyle(document.documentElement).fontSize);
} else {
const n = Number(h);
if (Number.isNaN(n)) {
throw new Error('h is not a number');
}
size = n;
}
} else {
size = h;
}
if (size < 0) throw Error('h is negative');
if (size < 0) throw Error('h is negative');
nr._h_indicators.push({
name,
size,
type: type ?? 'fixed',
data,
font
});
nr._h_indicators.push({
name,
size,
type: type ?? 'fixed',
data,
font
});
(nr as KnownAny)[name] = size;
(nr as KnownAny)[name] = size;
return nr as KnownAny;
};
return nr as KnownAny;
};
r.w_p = (h) => {
let size = 0;
if (typeof h === 'string') {
if (h.endsWith('rem')) {
const rem = Number(h.substring(0, h.length - 3));
if (Number.isNaN(rem)) {
throw new Error('h is not a valid rem value');
}
size =
rem *
Number.parseFloat(
getComputedStyle(document.documentElement).fontSize
);
} else {
const n = Number(h);
if (Number.isNaN(n)) {
throw new Error('h is not a number');
}
size = n;
}
} else {
size = h;
}
r._w_p = size * 2;
return r;
};
r.w_p = (h) => {
let size = 0;
if (typeof h === 'string') {
if (h.endsWith('rem')) {
const rem = Number(h.substring(0, h.length - 3));
if (Number.isNaN(rem)) {
throw new Error('h is not a valid rem value');
}
size = rem * Number.parseFloat(getComputedStyle(document.documentElement).fontSize);
} else {
const n = Number(h);
if (Number.isNaN(n)) {
throw new Error('h is not a number');
}
size = n;
}
} else {
size = h;
}
r._w_p = size * 2;
return r;
};
r.enforce = (inEnforce) => {
const e = inEnforce ?? r._enforce;
if (!e) return r;
r.enforce = (inEnforce) => {
const e = inEnforce ?? r._enforce;
if (!e) return r;
let h_sum = 0;
for (const i of r._h_indicators) {
h_sum += i.size;
}
let h_sum = 0;
for (const i of r._h_indicators) {
h_sum += i.size;
}
// TODO handle width
if (h_sum < r._height && !r._h_indicators.some((i) => i.type === 'left'))
return r;
// TODO handle width
if (h_sum < r._height && !r._h_indicators.some((i) => i.type === 'left')) return r;
let fSize = r._h_indicators.reduce((acc, i) => {
if (i.type !== 'fixed') return acc;
return acc + i.size;
}, 0);
let fSize = r._h_indicators.reduce((acc, i) => {
if (i.type !== 'fixed') return acc;
return acc + i.size;
}, 0);
// you are fucked anyway
if (fSize > r._height) return r;
// you are fucked anyway
if (fSize > r._height) return r;
const h_leftover = h_sum - fSize;
const th_leftover = r._height - fSize;
const h_leftover = h_sum - fSize;
const th_leftover = r._height - fSize;
const pr = r._h_indicators
.filter((i) => i.type === 'dyanmic' || i.type === 'text')
.map((i) => {
return [i, (i.size / h_leftover) * th_leftover] as const;
});
const pr = r._h_indicators
.filter((i) => i.type === 'dyanmic' || i.type === 'text')
.map((i) => {
return [i, (i.size / h_leftover) * th_leftover] as const;
});
for (const i of pr) {
let s = i[1];
if (i[0].type === 'text') {
s = Math.floor(
getFitSizeForList(
[i[0].data ?? ''],
r._width - r._w_p,
i[1],
0.5,
ctx,
i[0].font
) ?? i[1]
);
}
for (const i of pr) {
let s = i[1];
if (i[0].type === 'text') {
s = Math.floor(
getFitSizeForList([i[0].data ?? ''], r._width - r._w_p, i[1], 0.5, ctx, i[0].font) ?? i[1]
);
}
fSize += s;
(r as KnownAny)[i[0].name] = s;
}
fSize += s;
(r as KnownAny)[i[0].name] = s;
}
const left = r._h_indicators.filter((i) => i.type === 'left');
const len_left = left.length;
const left = r._h_indicators.filter((i) => i.type === 'left');
const len_left = left.length;
const rest = r._height - fSize;
const rest = r._height - fSize;
for (const i of left) {
(r as KnownAny)[i.name] = rest / len_left;
}
for (const i of left) {
(r as KnownAny)[i.name] = rest / len_left;
}
return r as KnownAny;
};
return r as KnownAny;
};
r.toEnfoceSize = (name) => {
if (!r._enforce) return;
return [r._width - r._w_p, r[name]];
};
r.toEnfoceSize = (name) => {
if (!r._enforce) return;
return [r._width - r._w_p, r[name]];
};
return r;
return r;
}
enforceSizeHelper.fromEnforceSize = (enforceSize?: EnforceSizeType) => {
return enforceSizeHelper(
enforceSize?.[0] ?? 0,
enforceSize?.[1] ?? 0,
!!enforceSize
);
return enforceSizeHelper(enforceSize?.[0] ?? 0, enforceSize?.[1] ?? 0, !!enforceSize);
};
export type PaddingManagerProps = {
width: number;
height: number;
fontSize?: number;
width: number;
height: number;
fontSize?: number;
};
export type Paddable = number | Axis | string[] | string | undefined;
export class PaddingManager {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
width: number;
height: number;
width: number;
height: number;
private _paddingLeft = 0;
private _paddingRight = 0;
private _paddingTop = 0;
private _paddingBot = 0;
private _paddingLeft = 0;
private _paddingRight = 0;
private _paddingTop = 0;
private _paddingBot = 0;
private _fontSize = 15;
private _fontSize = 15;
private _enforceSize?: EnforceSizeType;
private _enforceSize?: EnforceSizeType;
constructor({ width, height, fontSize }: PaddingManagerProps) {
this.width = width;
this.height = height;
constructor({ width, height, fontSize }: PaddingManagerProps) {
this.width = width;
this.height = height;
if (fontSize !== undefined) {
this._fontSize = fontSize;
}
if (fontSize !== undefined) {
this._fontSize = fontSize;
}
// This is used to calculate the size of text
this.canvas = document.createElement('canvas');
const ctx = this.canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to create context for the internal canvas');
}
this.ctx = ctx;
this.ctx.font = `${this._fontSize}px Open sans`;
}
// This is used to calculate the size of text
this.canvas = document.createElement('canvas');
const ctx = this.canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to create context for the internal canvas');
}
this.ctx = ctx;
this.ctx.font = `${this._fontSize}px Open sans`;
}
get paddedHeight() {
return this.height + this._paddingTop + this._paddingBot;
}
get paddedHeight() {
return this.height + this._paddingTop + this._paddingBot;
}
get paddedWidth() {
return this.width + this._paddingLeft + this._paddingRight;
}
get paddedWidth() {
return this.width + this._paddingLeft + this._paddingRight;
}
set fontSize(size: number) {
this._fontSize = size;
this.ctx.font = `${this._fontSize}px Open sans`;
}
set fontSize(size: number) {
this._fontSize = size;
this.ctx.font = `${this._fontSize}px Open sans`;
}
get fontSize() {
return this._fontSize;
}
get fontSize() {
return this._fontSize;
}
get left() {
return this._paddingLeft;
}
get left() {
return this._paddingLeft;
}
get right() {
return this._paddingRight;
}
get right() {
return this._paddingRight;
}
get top() {
return this._paddingTop;
}
get top() {
return this._paddingTop;
}
get bot() {
return this._paddingBot;
}
get bot() {
return this._paddingBot;
}
get translateString() {
return `translate(${this.left},${this.top})`;
}
get translateString() {
return `translate(${this.left},${this.top})`;
}
//
// Add padding
//
pad(side: Sides, padding: Paddable, angle?: number) {
let pn = 0;
//
// Add padding
//
pad(side: Sides, padding: Paddable, angle?: number) {
let pn = 0;
if (padding === undefined) {
return this;
}
if (typeof padding === 'number') {
pn = padding;
} else if (typeof padding === 'string') {
let a: number | undefined = undefined;
if (angle !== undefined) {
a = angle * (Math.PI / 180);
}
pn = this.ctx.measureText(padding).width * Math.sin(a ?? Math.PI / 2);
} else if (Array.isArray(padding)) {
pn = padding.reduce(
(acc, s) => Math.max(this.ctx.measureText(s).width, acc),
0
);
} else {
pn = padding.getPadding();
}
if (padding === undefined) {
return this;
}
if (typeof padding === 'number') {
pn = padding;
} else if (typeof padding === 'string') {
let a: number | undefined = undefined;
if (angle !== undefined) {
a = angle * (Math.PI / 180);
}
pn = this.ctx.measureText(padding).width * Math.sin(a ?? Math.PI / 2);
} else if (Array.isArray(padding)) {
pn = padding.reduce((acc, s) => Math.max(this.ctx.measureText(s).width, acc), 0);
} else {
pn = padding.getPadding();
}
switch (side) {
case 'left':
this._paddingLeft += pn;
return this;
case 'right':
this._paddingRight += pn;
return this;
case 'top':
this._paddingTop += pn;
return this;
case 'bot':
this._paddingBot += pn;
return this;
default:
throw new Error(`unknown side: ${side}`);
}
}
switch (side) {
case 'left':
this._paddingLeft += pn;
return this;
case 'right':
this._paddingRight += pn;
return this;
case 'top':
this._paddingTop += pn;
return this;
case 'bot':
this._paddingBot += pn;
return this;
default:
throw new Error(`unknown side: ${side}`);
}
}
padHoz(padding: Paddable) {
return this.pad('left', padding).pad('right', padding);
}
padHoz(padding: Paddable) {
return this.pad('left', padding).pad('right', padding);
}
padLeft(padding: Paddable, angle?: number) {
return this.pad('left', padding, angle);
}
padLeft(padding: Paddable, angle?: number) {
return this.pad('left', padding, angle);
}
padRight(padding: Paddable, angle?: number) {
return this.pad('right', padding, angle);
}
padRight(padding: Paddable, angle?: number) {
return this.pad('right', padding, angle);
}
padTop(padding: Paddable, angle?: number) {
return this.pad('top', padding, angle);
}
padTop(padding: Paddable, angle?: number) {
return this.pad('top', padding, angle);
}
padBot(padding: Paddable, angle?: number) {
return this.pad('bot', padding, angle);
}
padBot(padding: Paddable, angle?: number) {
return this.pad('bot', padding, angle);
}
resetPadding(side: Sides) {
switch (side) {
case 'left':
this._paddingLeft = 0;
return this;
case 'right':
this._paddingRight = 0;
return this;
case 'top':
this._paddingTop = 0;
return this;
case 'bot':
this._paddingBot = 0;
return this;
default:
throw new Error(`unknown side: ${side}`);
}
}
resetPadding(side: Sides) {
switch (side) {
case 'left':
this._paddingLeft = 0;
return this;
case 'right':
this._paddingRight = 0;
return this;
case 'top':
this._paddingTop = 0;
return this;
case 'bot':
this._paddingBot = 0;
return this;
default:
throw new Error(`unknown side: ${side}`);
}
}
padAll(n: number) {
this._paddingLeft += n;
this._paddingRight += n;
this._paddingTop += n;
this._paddingBot += n;
return this;
}
padAll(n: number) {
this._paddingLeft += n;
this._paddingRight += n;
this._paddingTop += n;
this._paddingBot += n;
return this;
}
enforce(inEnforce?: EnforceSizeType) {
const enforce = this._enforceSize ?? inEnforce;
if (enforce === undefined) return this;
enforce(inEnforce?: EnforceSizeType) {
const enforce = this._enforceSize ?? inEnforce;
if (enforce === undefined) return this;
if (this.paddedWidth !== enforce[0]) {
this.width = enforce[0] - this.left - this.right;
}
if (this.paddedHeight !== enforce[1]) {
this.height = enforce[1] - this.top - this.bot;
}
if (this.paddedWidth !== enforce[0]) {
this.width = enforce[0] - this.left - this.right;
}
if (this.paddedHeight !== enforce[1]) {
this.height = enforce[1] - this.top - this.bot;
}
return this;
}
return this;
}
}
export function countReducer(acc: Record<number, number>, a: { c: number | string | Date }) {
const c = new Date(a.c);
c.setHours(0);
c.setMinutes(0);
c.setSeconds(0);
c.setMilliseconds(0);
const ct = c.getTime();
const c = new Date(a.c);
c.setHours(0);
c.setMinutes(0);
c.setSeconds(0);
c.setMilliseconds(0);
const ct = c.getTime();
if (acc[ct]) {
acc[ct] += 1;
} else {
acc[ct] = 1;
}
return acc;
if (acc[ct]) {
acc[ct] += 1;
} else {
acc[ct] = 1;
}
return acc;
}