Dashboard final version, TODO: update main sidebar menu

This commit is contained in:
Simon Pocrnjič
2025-11-23 21:33:01 +01:00
parent c3de189e9d
commit c1ac92efbf
67 changed files with 5195 additions and 844 deletions
@@ -0,0 +1,80 @@
<script setup>
import { computed, ref } from 'vue'
import { useChartContext } from './interface'
const props = defineProps({
order: { type: Array, required: false }, // explicit ordering of keys
modelValue: { type: Array, required: false }, // deprecated alias
activeKeys: { type: Array, required: false }, // v-model:activeKeys target
class: { type: [String, Array, Object], required: false },
})
const emit = defineEmits(['update:activeKeys'])
const { config } = useChartContext()
// Derive ordered keys from config
const allKeys = computed(() => {
const keys = Object.keys(config)
if (props.order && props.order.length) {
return props.order.filter(k => keys.includes(k))
}
return keys
})
// Internal active state (if parent not controlling)
const internalActive = ref(allKeys.value.reduce((acc, k) => { acc[k] = true; return acc }, {}))
const activeMap = computed(() => {
// If parent passes controlled array use that
if (props.activeKeys && props.activeKeys.length) {
return props.activeKeys.reduce((acc, k) => { acc[k] = true; return acc }, {})
}
return internalActive.value
})
const items = computed(() => allKeys.value.map(k => {
const series = config[k] || {}
const color = series.color || (series.theme && (series.theme.light || series.theme.dark)) || 'var(--foreground)'
return { key: k, label: series.label || k, color, active: !!activeMap.value[k] }
}))
function toggle(key) {
// controlled mode
if (props.activeKeys) {
const next = items.value.filter(i => i.key === key ? !i.active : i.active).map(i => i.key)
// If item was active we remove it, else add it
const wasActive = activeMap.value[key]
const result = wasActive
? props.activeKeys.filter(k => k !== key)
: [...props.activeKeys, key]
emit('update:activeKeys', result)
return
}
// uncontrolled mode
internalActive.value[key] = !internalActive.value[key]
const result = Object.entries(internalActive.value).filter(([, v]) => v).map(([k]) => k)
emit('update:activeKeys', result)
}
</script>
<template>
<div :class="['flex items-center justify-center flex-wrap gap-4 text-xs select-none', props.class]">
<button
v-for="item in items"
:key="item.key"
type="button"
:class="[
'flex items-center gap-2 transition-colors',
item.active ? 'opacity-100' : 'opacity-40',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm'
]"
@click="toggle(item.key)"
>
<span class="h-2.5 w-2.5 rounded-[3px] border border-border" :style="{ background: item.color }" />
<span>{{ item.label }}</span>
</button>
</div>
</template>
<style scoped></style>
@@ -0,0 +1,32 @@
<script setup>
import { computed } from 'vue';
import { provideChartContext } from './interface';
const props = defineProps({
config: { type: Object, required: false, default: () => ({}) },
class: { type: [String, Array, Object], required: false },
cursor: { type: Boolean, default: true },
id: { type: String, required: false },
});
// Provide context (even if empty) so descendants can attempt to read series config.
const ctx = provideChartContext(props.config, props.id);
const chartDomId = computed(() => `chart-${ctx.id}`);
</script>
<template>
<div
data-chart-container
:data-chart="chartDomId"
:class="['relative w-full flex flex-col', props.class]"
:style="{
// Default color variables; series components can override via inline style or CSS theme logic.
'--vis-primary-color': 'hsl(var(--primary))',
'--vis-secondary-color': 'hsl(var(--secondary))',
'--vis-crosshair-line-stroke-width': props.cursor ? '1px' : '0px',
'--vis-font-family': 'var(--font-sans)',
}"
>
<slot />
</div>
</template>
@@ -0,0 +1,54 @@
<script setup>
import { omit } from "@unovis/ts";
import { VisCrosshair, VisTooltip } from "@unovis/vue";
import { createApp } from "vue";
import { ChartTooltip } from ".";
const props = defineProps({
colors: { type: Array, required: false, default: () => [] },
index: { type: String, required: true },
// items now optional when using external template factory
items: { type: Array, required: false, default: () => [] },
customTooltip: { type: null, required: false },
labelFormatter: { type: Function, required: false },
// template override (e.g., componentToString(...))
template: { type: Function, required: false },
});
// Use weakmap to store reference to each datapoint for Tooltip
const wm = new WeakMap();
function internalTemplate(d) {
// If we have cached markup and no custom formatter altering title, reuse.
if (wm.has(d) && !props.labelFormatter && !props.template) {
return wm.get(d);
}
// If external template provided, delegate directly
if (props.template) {
const html = props.template(d, d[props.index]);
wm.set(d, html);
return html;
}
const componentDiv = document.createElement("div");
const omittedData = Object.entries(omit(d, [props.index])).map(([key, value]) => {
const legendReference = props.items.find((i) => i.name === key);
return { ...legendReference, value };
});
const TooltipComponent = props.customTooltip ?? ChartTooltip;
createApp(TooltipComponent, {
title: d[props.index],
data: omittedData,
labelFormatter: props.labelFormatter,
}).mount(componentDiv);
wm.set(d, componentDiv.innerHTML);
return componentDiv.innerHTML;
}
function color(d, i) {
return props.colors[i] ?? "transparent";
}
</script>
<template>
<VisTooltip :horizontal-shift="20" :vertical-shift="20" />
<VisCrosshair :template="internalTemplate" :color="color" />
</template>
@@ -0,0 +1,66 @@
<script setup>
import { BulletLegend } from "@unovis/ts";
import { VisBulletLegend } from "@unovis/vue";
import { nextTick, onMounted, ref } from "vue";
import { buttonVariants } from '@/Components/ui/button';
const props = defineProps({
items: { type: Array, required: true, default: () => [] },
});
const emits = defineEmits(["legendItemClick", "update:items"]);
const elRef = ref();
function keepStyling() {
const selector = `.${BulletLegend.selectors.item}`;
nextTick(() => {
const elements = elRef.value?.querySelectorAll(selector);
const classes = buttonVariants({ variant: "ghost", size: "xs" }).split(" ");
elements?.forEach((el) =>
el.classList.add(...classes, "!inline-flex", "!mr-2"),
);
});
}
onMounted(() => {
keepStyling();
});
function onLegendItemClick(d, i) {
emits("legendItemClick", d, i);
const isBulletActive = !props.items[i].inactive;
const isFilterApplied = props.items.some((i) => i.inactive);
if (isFilterApplied && isBulletActive) {
// reset filter
emits(
"update:items",
props.items.map((item) => ({ ...item, inactive: false })),
);
} else {
// apply selection, set other item as inactive
emits(
"update:items",
props.items.map((item) =>
item.name === d.name
? { ...d, inactive: false }
: { ...item, inactive: true },
),
);
}
keepStyling();
}
</script>
<template>
<div
ref="elRef"
class="w-max"
:style="{
'--vis-legend-bullet-size': '16px',
}"
>
<VisBulletLegend :items="items" :on-legend-item-click="onLegendItemClick" />
</div>
</template>
@@ -0,0 +1,73 @@
<script setup>
import { omit } from "@unovis/ts";
import { VisTooltip } from "@unovis/vue";
import { createApp } from "vue";
import { ChartTooltip } from ".";
const props = defineProps({
selector: { type: String, required: true },
index: { type: String, required: true },
items: { type: Array, required: false },
valueFormatter: { type: Function, required: false },
customTooltip: { type: null, required: false },
});
// Use weakmap to store reference to each datapoint for Tooltip
const wm = new WeakMap();
function template(d, i, elements) {
const valueFormatter = props.valueFormatter ?? ((tick) => `${tick}`);
if (props.index in d) {
if (wm.has(d)) {
return wm.get(d);
} else {
const componentDiv = document.createElement("div");
const omittedData = Object.entries(omit(d, [props.index])).map(
([key, value]) => {
const legendReference = props.items?.find((i) => i.name === key);
return { ...legendReference, value: valueFormatter(value) };
},
);
const TooltipComponent = props.customTooltip ?? ChartTooltip;
createApp(TooltipComponent, {
title: d[props.index],
data: omittedData,
}).mount(componentDiv);
wm.set(d, componentDiv.innerHTML);
return componentDiv.innerHTML;
}
} else {
const data = d.data;
if (wm.has(data)) {
return wm.get(data);
} else {
const style = getComputedStyle(elements[i]);
const omittedData = [
{
name: data.name,
value: valueFormatter(data[props.index]),
color: style.fill,
},
];
const componentDiv = document.createElement("div");
const TooltipComponent = props.customTooltip ?? ChartTooltip;
createApp(TooltipComponent, {
title: d[props.index],
data: omittedData,
}).mount(componentDiv);
wm.set(d, componentDiv.innerHTML);
return componentDiv.innerHTML;
}
}
}
</script>
<template>
<VisTooltip
:horizontal-shift="20"
:vertical-shift="20"
:triggers="{
[selector]: template,
}"
/>
</template>
@@ -0,0 +1,41 @@
<script setup>
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/Components/ui/card';
defineProps({
title: { type: String, required: false },
data: { type: Array, required: true },
});
</script>
<template>
<Card class="text-sm">
<CardHeader v-if="title" class="p-3 border-b">
<CardTitle>
{{ title }}
</CardTitle>
</CardHeader>
<CardContent class="p-3 min-w-[180px] flex flex-col gap-1">
<div v-for="(item, key) in data" :key="key" class="flex justify-between">
<div class="flex items-center">
<span class="w-2.5 h-2.5 mr-2">
<svg width="100%" height="100%" viewBox="0 0 30 30">
<path
d=" M 15 15 m -14, 0 a 14,14 0 1,1 28,0 a 14,14 0 1,1 -28,0"
:stroke="item.color"
:fill="item.color"
stroke-width="1"
/>
</svg>
</span>
<span>{{ item.name }}</span>
</div>
<span class="font-semibold ml-4">{{ item.value }}</span>
</div>
</CardContent>
</Card>
</template>
@@ -0,0 +1,85 @@
<script setup>
// Advanced tooltip component (original implementation) inspired by external patterns.
import { computed } from 'vue';
const props = defineProps({
hideLabel: { type: Boolean, default: false },
hideIndicator: { type: Boolean, default: false },
indicator: { type: String, default: 'dot' }, // 'dot' | 'line' | 'dashed'
nameKey: { type: String, required: false },
labelKey: { type: String, required: false },
labelFormatter: { type: Function, required: false },
payload: { type: Object, required: false, default: () => ({}) },
config: { type: Object, required: false, default: () => ({}) },
class: { type: [String, Array, Object], required: false },
color: { type: String, required: false },
x: { type: [Number, Date, String], required: false },
});
// Build array of entries referencing config for label & color
const entries = computed(() => {
return Object.entries(props.payload)
.map(([key, value]) => {
const seriesKey = props.nameKey || key;
const itemConfig = props.config[seriesKey] || props.config[key] || {};
const indicatorColor = itemConfig.color || props.color;
return { key, value, itemConfig, indicatorColor };
})
.filter(e => e.itemConfig && (e.itemConfig.label || e.value !== undefined));
});
const singleSeries = computed(() => entries.value.length === 1 && props.indicator !== 'dot');
const formattedLabel = computed(() => {
if (props.hideLabel) return null;
if (props.labelFormatter && props.x !== undefined) {
return props.labelFormatter(props.x);
}
if (props.labelKey) {
const cfg = props.config[props.labelKey];
return cfg?.label || props.payload[props.labelKey];
}
return props.x instanceof Date ? props.x.toLocaleDateString() : props.x;
});
function formatValue(v) {
if (v == null) return '';
if (typeof v === 'number') return v.toLocaleString();
return v;
}
</script>
<template>
<div :class="['border border-border/50 bg-background min-w-32 rounded-lg px-2.5 py-1.5 text-xs shadow-xl', props.class]">
<div v-if="!singleSeries && formattedLabel" class="font-medium mb-1">{{ formattedLabel }}</div>
<div class="grid gap-1.5">
<div
v-for="{ key, value, itemConfig, indicatorColor } in entries"
:key="key"
class="flex w-full flex-wrap items-stretch gap-2"
:class="indicator === 'dot' ? 'items-center' : 'items-start'"
>
<!-- Indicator -->
<template v-if="!hideIndicator">
<div
:class="[
'shrink-0 rounded-[2px] border-border',
indicator === 'dot' && 'h-2.5 w-2.5',
indicator === 'line' && 'w-1 h-4',
indicator === 'dashed' && 'w-0 h-4 border-[1.5px] border-dashed bg-transparent',
singleSeries && indicator === 'dashed' && 'my-0.5'
]"
:style="{ '--color-bg': indicatorColor, '--color-border': indicatorColor }"
/>
</template>
<div :class="['flex flex-1 justify-between leading-none', singleSeries ? 'items-end' : 'items-center']">
<div class="grid gap-1.5">
<div v-if="singleSeries && formattedLabel" class="font-medium">{{ formattedLabel }}</div>
<span class="text-muted-foreground">{{ itemConfig.label || formatValue(value) }}</span>
</div>
<span v-if="value !== undefined" class="font-mono font-medium tabular-nums">{{ formatValue(value) }}</span>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,38 @@
import { createApp, h } from 'vue'
// Simple cache map to avoid re-rendering identical payloads.
const _cache = new Map()
function serializeKey(obj) {
try {
return JSON.stringify(obj, Object.keys(obj).sort())
} catch (e) {
return Math.random().toString(36)
}
}
// Factory returning template function for Unovis Crosshair.
// config: chart series configuration
// Component: Vue component to render
// extraProps: static props (e.g. labelKey, labelFormatter)
export function componentToString(config, Component, extraProps = {}) {
return function (_data, x) {
const row = _data && _data.data ? _data.data : _data
// Build series-only payload (exclude non-config fields like date/dateLabel)
const seriesPayload = {}
Object.keys(config).forEach(k => {
if (row && row[k] !== undefined) seriesPayload[k] = row[k]
})
const cacheKeyBase = { ...seriesPayload, __x: x }
const key = serializeKey(cacheKeyBase)
if (_cache.has(key)) return _cache.get(key)
const el = document.createElement('div')
const app = createApp(Component, { ...extraProps, payload: seriesPayload, config, x: row?.date ?? x })
app.mount(el)
const html = el.innerHTML
app.unmount()
_cache.set(key, html)
return html
}
}
+28
View File
@@ -0,0 +1,28 @@
export { default as ChartCrosshair } from "./ChartCrosshair.vue";
export { default as ChartLegend } from "./ChartLegend.vue";
export { default as ChartSingleTooltip } from "./ChartSingleTooltip.vue";
export { default as ChartTooltip } from "./ChartTooltip.vue";
export { default as ChartContainer } from "./ChartContainer.vue";
export { default as ChartTooltipContent } from "./ChartTooltipContent.vue";
export { componentToString } from "./componentToString";
export { provideChartContext, useChartContext } from "./interface";
export { default as ChartAutoLegend } from "./ChartAutoLegend.vue";
export function defaultColors(count = 3) {
const quotient = Math.floor(count / 2);
const remainder = count % 2;
const primaryCount = quotient + remainder;
const secondaryCount = quotient;
return [
...Array.from(new Array(primaryCount).keys()).map(
(i) => `hsl(var(--vis-primary-color) / ${1 - (1 / primaryCount) * i})`,
),
...Array.from(new Array(secondaryCount).keys()).map(
(i) =>
`hsl(var(--vis-secondary-color) / ${1 - (1 / secondaryCount) * i})`,
),
];
}
export * from "./interface";
@@ -0,0 +1,49 @@
// Chart interface and context helpers
// This is a fresh, original implementation inspired conceptually by patterns
// observed in external registries. No code copied.
import { inject, provide, reactive } from 'vue';
/**
* @typedef {Object} ChartSeriesConfig
* @property {string|import('vue').Component} [label] Display label or component
* @property {import('vue').Component} [icon] Optional icon component
* @property {string} [color] Static CSS color value (e.g. 'var(--chart-1)')
* @property {Object} [theme] Optional theme map: { light: string, dark: string }
*/
/**
* @typedef {Object.<string, ChartSeriesConfig>} ChartConfig
* Keys are series identifiers. Each value declares label/icon and either a
* static color or a theme object with light/dark variants.
*/
const ChartContextSymbol = Symbol('ChartContext');
let _idCounter = 0;
/**
* Provide chart context for descendants.
* @param {ChartConfig} config Reactive or plain config object.
* @param {string} [explicitId] Optional id override.
* @returns {{ id: string, config: ChartConfig }}
*/
export function provideChartContext(config, explicitId) {
const id = explicitId || `c${Date.now().toString(36)}${(++_idCounter).toString(36)}`;
const ctx = { id, config: reactive(config) };
provide(ChartContextSymbol, ctx);
return ctx;
}
/**
* Inject previously provided chart context.
* @returns {{ id: string, config: ChartConfig }}
*/
export function useChartContext() {
const ctx = inject(ChartContextSymbol, null);
if (!ctx) {
throw new Error('useChartContext() called without a provider. Wrap in <ChartContainer>.');
}
return ctx;
}
export {}; // preserve module boundaries