): string {
+ return JSON.stringify(key, Object.keys(key).sort())
+}
+
+interface Constructor {
+ __isFragment?: never
+ __isTeleport?: never
+ __isSuspense?: never
+ new (...args: any[]): {
+ $props: P
+ }
+}
+
+export function componentToString
(config: MaybeRef, component: Constructor, props?: P) {
+ if (!isClient)
+ return
+
+ // This function will be called once during mount lifecycle
+ const id = useId()
+
+ // https://unovis.dev/docs/auxiliary/Crosshair#component-props
+ return (_data: any, x: number | Date) => {
+ const data = "data" in _data ? _data.data : _data
+ const normalizedX = typeof x === "number" ? Math.round(x) : x.getTime()
+ const serializedKey = `${id}-${normalizedX}-${serializeKey(data)}`
+ const cachedContent = cache.get(serializedKey)
+ if (cachedContent)
+ return cachedContent
+
+ const vnode = h(component, { ...props, payload: data, config: unref(config), x })
+ const div = document.createElement("div")
+ render(vnode, div)
+ cache.set(serializedKey, div.innerHTML)
+ return div.innerHTML
+ }
+}
diff --git a/resources/js/dashboard/components/widgets/Graph.vue b/resources/js/dashboard/components/widgets/Graph.vue
index a53c2ba5f..7c69cf18a 100644
--- a/resources/js/dashboard/components/widgets/Graph.vue
+++ b/resources/js/dashboard/components/widgets/Graph.vue
@@ -1,11 +1,12 @@
@@ -31,10 +33,11 @@
v-bind="props"
class="[&_svg]:rounded-b-[calc(.5rem-1px)] [&_svg]:overflow-visible"
:class="[
- !widget.height ? 'aspect-(--ratio)' : '',
+ widget.height ? 'h-(--height)' : 'aspect-(--ratio)',
]"
:style="{
- '--ratio': `${widget.ratioX} / ${widget.ratioY}`,
+ '--ratio': widget.height ? null : `${widget.ratioX} / ${widget.ratioY}`,
+ '--height': widget.height ? `${widget.height}px` : null,
}"
/>
diff --git a/resources/js/dashboard/components/widgets/graph/Area.vue b/resources/js/dashboard/components/widgets/graph/Area.vue
new file mode 100644
index 000000000..96ff53181
--- /dev/null
+++ b/resources/js/dashboard/components/widgets/graph/Area.vue
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/dashboard/components/widgets/graph/Bar.vue b/resources/js/dashboard/components/widgets/graph/Bar.vue
index 2b1bc9f93..66a5a5a4e 100644
--- a/resources/js/dashboard/components/widgets/graph/Bar.vue
+++ b/resources/js/dashboard/components/widgets/graph/Bar.vue
@@ -1,62 +1,78 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/dashboard/components/widgets/graph/Line.vue b/resources/js/dashboard/components/widgets/graph/Line.vue
index 8121aa83b..bbf41757f 100644
--- a/resources/js/dashboard/components/widgets/graph/Line.vue
+++ b/resources/js/dashboard/components/widgets/graph/Line.vue
@@ -1,65 +1,77 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/dashboard/components/widgets/graph/Pie.vue b/resources/js/dashboard/components/widgets/graph/Pie.vue
index 55e068faf..a405c2654 100644
--- a/resources/js/dashboard/components/widgets/graph/Pie.vue
+++ b/resources/js/dashboard/components/widgets/graph/Pie.vue
@@ -1,69 +1,160 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/dashboard/components/widgets/graph/useApexCharts.ts b/resources/js/dashboard/components/widgets/graph/useApexCharts.ts
deleted file mode 100644
index 92efca3a5..000000000
--- a/resources/js/dashboard/components/widgets/graph/useApexCharts.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { ApexOptions } from 'apexcharts';
-import en from "apexcharts/dist/locales/en.json";
-import fr from "apexcharts/dist/locales/fr.json";
-import ru from "apexcharts/dist/locales/ru.json";
-import es from "apexcharts/dist/locales/es.json";
-import de from "apexcharts/dist/locales/de.json";
-import merge from 'lodash/merge';
-import { computed, MaybeRefOrGetter, ref, toValue, useTemplateRef } from "vue";
-import { GraphWidgetData } from "@/types";
-import { VueApexChartsComponent } from "vue3-apexcharts";
-import { useResizeObserver } from "@vueuse/core";
-import { DashboardWidgetProps } from "@/dashboard/types";
-import debounce from "lodash/debounce";
-import { useColorMode } from "@/composables/useColorMode";
-
-export function useApexCharts(
- props: DashboardWidgetProps,
- additionalOptions: ({ width }: { width: number }) => ApexOptions
-) {
- const apexChartsComponent = useTemplateRef('apexChartsComponent');
- const el = computed(() => apexChartsComponent.value?.$el);
- const width = ref(0);
- const redraw = debounce(() => {
- apexChartsComponent.value.chart?.updateOptions({}, true);
- width.value = el.value.clientWidth;
- el.value.style.overflow = 'visible';
- }, 100);
- const mode = useColorMode();
- useResizeObserver(apexChartsComponent, () => {
- el.value.style.overflow = 'hidden';
- if(!width.value) {
- width.value = el.value.clientWidth;
- }
- redraw();
- });
- const options = computed(() => {
- const widget = props.widget;
- const baseOptions: ApexOptions = {
- chart: {
- height: widget.height ?? '100%',
- width: '100%',
- parentHeightOffset: 0,
- animations: {
- enabled: false,
- },
- toolbar: {
- show: false,
- },
- zoom: {
- enabled: false,
- },
- selection: {
- enabled: false,
- },
- locales: [
- en, fr, ru, es, de,
- ],
- defaultLocale: document.documentElement.lang,
- redrawOnParentResize: false,
- redrawOnWindowResize: false,
- background: 'transparent',
- },
- legend: {
- show: widget.showLegend && !widget.minimal,
- showForSingleSeries: true,
- },
- theme: {
- mode: mode.value
- },
- tooltip: {
- y: {
- title: {
- // @ts-ignore
- formatter: (seriesName, {seriesIndex}) =>
- seriesName !== `series-${seriesIndex + 1}` ? `${seriesName}:` : ''
- }
- }
- },
- };
-
- return merge(baseOptions, additionalOptions({ width: width.value }));
- });
-
- return {
- apexChartsComponent,
- options,
- };
-}
diff --git a/resources/js/dashboard/components/widgets/graph/useXYChart.ts b/resources/js/dashboard/components/widgets/graph/useXYChart.ts
new file mode 100644
index 000000000..62285f302
--- /dev/null
+++ b/resources/js/dashboard/components/widgets/graph/useXYChart.ts
@@ -0,0 +1,139 @@
+import { DashboardWidgetProps } from "@/dashboard/types";
+import { GraphWidgetData } from "@/types";
+import { computed, reactive, toRefs } from "vue";
+import { XYComponentConfigInterface } from "@unovis/ts/core/xy-component/config";
+import {
+ AxisConfigInterface,
+ ColorAccessor,
+ FitMode,
+ Scale,
+ TextAlign,
+ TrimMode,
+ getNearest,
+} from "@unovis/ts";
+import { ChartConfig, ChartTooltipContent, componentToString } from "@/components/ui/chart";
+export type Datum = number[];
+
+export function useXYChart(props: DashboardWidgetProps) {
+ const data = computed((): Datum[] => props.value?.datasets?.reduce((res, dataset, i) => {
+ dataset.data.forEach((v, j) => {
+ res[j] ??= [];
+ res[j][i] = v;
+ });
+ return res;
+ }, []));
+ const timeScale = true;
+ const x: XYComponentConfigInterface['x'] = (d, i) => {
+ return props.widget.displayHorizontalAxisAsTimeline
+ ? new Date(props.value.labels[i]).getTime()
+ : i;
+ };
+ const y = computed((): XYComponentConfigInterface['y'] => props.value?.datasets.map((dataset, i) => (d) => d[i]));
+ const xScale = computed((): XYComponentConfigInterface['xScale'] => {
+ return props.widget.displayHorizontalAxisAsTimeline
+ ? Scale.scaleUtc() as any
+ : undefined
+ });
+ const color = computed((): ColorAccessor => props.value?.datasets.map((dataset, i) => dataset.color));
+
+ const chartConfig = computed((): ChartConfig =>
+ Object.fromEntries(props.value?.datasets.map((dataset, i) => [i, ({ label: dataset.label, color: dataset.color })]))
+ );
+
+ const tooltipTemplate = componentToString(chartConfig, ChartTooltipContent, {
+ labelFormatter: (x) => {
+ if(props.widget.displayHorizontalAxisAsTimeline) {
+ const nearestDate = new Date(
+ getNearest(
+ props.value.labels.map((label) => new Date(label).getTime()),
+ (x as Date).getTime(),
+ v => v
+ )
+ );
+ return formatDate(nearestDate);
+ }
+ return props.value.labels[Math.round(x as number)];
+ }
+ });
+
+ const rotate = computed(() =>
+ !props.widget.options.horizontal
+ && !props.widget.enableHorizontalAxisLabelSampling
+ && !props.widget.displayHorizontalAxisAsTimeline
+ && props.value?.labels?.length >= 10
+ );
+
+
+ const xAxisConfig = computed((): AxisConfigInterface => ({
+ tickValues: (() => {
+ if(props.widget.displayHorizontalAxisAsTimeline) {
+ return props.value.labels.length < 10
+ ? props.value.labels.map((label) => new Date(label))
+ : undefined as any; // let unovis handle number of ticks
+ }
+ if(!props.widget.enableHorizontalAxisLabelSampling) {
+ return props.value.labels.map((_, i) => i);
+ }
+ })(),
+ tickFormat: (tick, i) => {
+ if(props.widget.displayHorizontalAxisAsTimeline) {
+ return formatDate(tick as Date);
+ }
+ return props.value?.labels?.[tick as number] ?? '';
+ },
+ tickTextTrimType: TrimMode.End,
+ // tickTextAlign: rotate.value ? TextAlign.Left : props.widget.options.horizontal ? TextAlign.Right : TextAlign.Center,
+ tickTextAlign: rotate.value ? TextAlign.Right : props.widget.options.horizontal ? TextAlign.Right : TextAlign.Center,
+ tickTextFitMode: rotate.value ? FitMode.Wrap : FitMode.Trim,
+ // tickTextAngle: rotate.value ? 45 : undefined,
+ tickTextAngle: rotate.value ? -45 : undefined,
+ tickTextWidth: rotate.value ? 100 : undefined,
+ }));
+
+
+ const needsDecimals = computed(() =>
+ props.value?.datasets.every(dataset =>
+ dataset.data.every(value => Math.abs(value) > 0 && Math.abs(value) < 1)
+ )
+ );
+ const yAxisConfig = computed((): AxisConfigInterface => ({
+ tickFormat: (tick) => {
+ if(!Number.isInteger(tick) && !needsDecimals.value) {
+ return '';
+ }
+ }
+ }));
+
+
+ return {
+ data,
+ x,
+ y,
+ color,
+ tooltipTemplate,
+ timeScale,
+ xScale,
+ chartConfig,
+ xAxisConfig,
+ yAxisConfig,
+ };
+}
+
+
+function utc(d: Date) {
+ const d2 = new Date(d);
+ d2.setMinutes(d2.getMinutes() + d2.getTimezoneOffset());
+ return d2;
+}
+
+function formatDate(date: Date) {
+ date = utc(date);
+ const hasHour = date.getHours() !== 0 || date.getMinutes() !== 0;
+ return new Intl.DateTimeFormat(undefined, {
+ day: '2-digit',
+ month: 'short',
+ hour: hasHour ? '2-digit' : undefined,
+ minute: hasHour ? '2-digit' : undefined,
+ })
+ .format(date);
+}
diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts
index ae77e0f17..838f498b2 100644
--- a/resources/js/types/generated.d.ts
+++ b/resources/js/types/generated.d.ts
@@ -660,10 +660,17 @@ export type GraphWidgetData = {
ratioX: number | null;
ratioY: number | null;
height: number | null;
- dateLabels: boolean;
- options: { curved: boolean; horizontal: boolean };
+ displayHorizontalAxisAsTimeline: boolean;
+ enableHorizontalAxisLabelSampling: boolean;
+ options: {
+ curved: boolean;
+ horizontal: boolean;
+ showDots: boolean;
+ gradient: boolean;
+ opacity: any;
+ };
};
-export type GraphWidgetDisplay = "bar" | "line" | "pie";
+export type GraphWidgetDisplay = "bar" | "line" | "pie" | "area";
export type IconData = {
svg: string | null;
name: string | null;
diff --git a/src/Dashboard/Widgets/IsXYChart.php b/src/Dashboard/Widgets/IsXYChart.php
new file mode 100644
index 000000000..0ca70b8c5
--- /dev/null
+++ b/src/Dashboard/Widgets/IsXYChart.php
@@ -0,0 +1,23 @@
+displayHorizontalAxisAsTimeline = $displayAsTimeline;
+
+ return $this;
+ }
+
+ public function setEnableHorizontalAxisLabelSampling(bool $enableLabelSampling = true): self
+ {
+ $this->enableHorizontalAxisLabelSampling = $enableLabelSampling;
+
+ return $this;
+ }
+}
diff --git a/src/Dashboard/Widgets/SharpAreaGraphWidget.php b/src/Dashboard/Widgets/SharpAreaGraphWidget.php
new file mode 100644
index 000000000..b43e4b68c
--- /dev/null
+++ b/src/Dashboard/Widgets/SharpAreaGraphWidget.php
@@ -0,0 +1,56 @@
+display = 'area';
+
+ return $widget;
+ }
+
+ public function setCurvedLines(bool $curvedLines = true): self
+ {
+ $this->curvedLines = $curvedLines;
+
+ return $this;
+ }
+
+ public function setOpacity(float $opacity): self
+ {
+ $this->opacity = $opacity;
+
+ return $this;
+ }
+
+ public function setShowGradient(bool $gradient = true): self
+ {
+ $this->gradient = $gradient;
+
+ return $this;
+ }
+
+ public function toArray(): array
+ {
+ return array_merge(
+ parent::toArray(), [
+ 'displayHorizontalAxisAsTimeline' => $this->displayHorizontalAxisAsTimeline,
+ 'enableHorizontalAxisLabelSampling' => $this->enableHorizontalAxisLabelSampling,
+ 'options' => [
+ 'curved' => $this->curvedLines,
+ 'gradient' => $this->gradient,
+ 'opacity' => $this->opacity,
+ ],
+ ],
+ );
+ }
+}
diff --git a/src/Dashboard/Widgets/SharpBarGraphWidget.php b/src/Dashboard/Widgets/SharpBarGraphWidget.php
index 1cf4a4738..143d9d0e8 100644
--- a/src/Dashboard/Widgets/SharpBarGraphWidget.php
+++ b/src/Dashboard/Widgets/SharpBarGraphWidget.php
@@ -4,8 +4,9 @@
class SharpBarGraphWidget extends SharpGraphWidget
{
+ use IsXYChart;
+
protected bool $horizontal = false;
- protected bool $displayHorizontalAxisAsTimeline = false;
public static function make(string $key): SharpBarGraphWidget
{
@@ -22,18 +23,12 @@ public function setHorizontal(bool $horizontal = true): self
return $this;
}
- public function setDisplayHorizontalAxisAsTimeline(bool $displayAsTimeline = true): self
- {
- $this->displayHorizontalAxisAsTimeline = $displayAsTimeline;
-
- return $this;
- }
-
public function toArray(): array
{
return array_merge(
parent::toArray(), [
- 'dateLabels' => $this->displayHorizontalAxisAsTimeline,
+ 'displayHorizontalAxisAsTimeline' => $this->displayHorizontalAxisAsTimeline,
+ 'enableHorizontalAxisLabelSampling' => $this->enableHorizontalAxisLabelSampling,
'options' => [
'horizontal' => $this->horizontal,
],
diff --git a/src/Dashboard/Widgets/SharpGraphWidget.php b/src/Dashboard/Widgets/SharpGraphWidget.php
index 53a08eb78..f27c19ea9 100644
--- a/src/Dashboard/Widgets/SharpGraphWidget.php
+++ b/src/Dashboard/Widgets/SharpGraphWidget.php
@@ -55,7 +55,7 @@ protected function validationRules(): array
return [
'display' => [
'required',
- 'in:bar,line,pie',
+ 'in:bar,line,pie,area',
],
];
}
diff --git a/src/Dashboard/Widgets/SharpLineGraphWidget.php b/src/Dashboard/Widgets/SharpLineGraphWidget.php
index 18e34cca9..136c97106 100644
--- a/src/Dashboard/Widgets/SharpLineGraphWidget.php
+++ b/src/Dashboard/Widgets/SharpLineGraphWidget.php
@@ -4,8 +4,10 @@
class SharpLineGraphWidget extends SharpGraphWidget
{
+ use IsXYChart;
+
protected bool $curvedLines = true;
- protected bool $displayHorizontalAxisAsTimeline = false;
+ protected bool $showDots = false;
public static function make(string $key): SharpLineGraphWidget
{
@@ -22,9 +24,9 @@ public function setCurvedLines(bool $curvedLines = true): self
return $this;
}
- public function setDisplayHorizontalAxisAsTimeline(bool $displayAsTimeline = true): self
+ public function setShowDots(bool $showDots = true): self
{
- $this->displayHorizontalAxisAsTimeline = $displayAsTimeline;
+ $this->showDots = $showDots;
return $this;
}
@@ -33,9 +35,11 @@ public function toArray(): array
{
return array_merge(
parent::toArray(), [
- 'dateLabels' => $this->displayHorizontalAxisAsTimeline,
+ 'displayHorizontalAxisAsTimeline' => $this->displayHorizontalAxisAsTimeline,
+ 'enableHorizontalAxisLabelSampling' => $this->enableHorizontalAxisLabelSampling,
'options' => [
'curved' => $this->curvedLines,
+ 'showDots' => $this->showDots,
],
],
);
diff --git a/src/Data/Dashboard/Widgets/GraphWidgetData.php b/src/Data/Dashboard/Widgets/GraphWidgetData.php
index d1df7e7ed..fd2aecab5 100644
--- a/src/Data/Dashboard/Widgets/GraphWidgetData.php
+++ b/src/Data/Dashboard/Widgets/GraphWidgetData.php
@@ -33,10 +33,14 @@ public function __construct(
public ?int $ratioX = null,
public ?int $ratioY = null,
public ?int $height = null,
- public bool $dateLabels = false,
+ public bool $displayHorizontalAxisAsTimeline = false,
+ public bool $enableHorizontalAxisLabelSampling = false,
#[TypeScriptType([
'curved' => 'boolean',
'horizontal' => 'boolean',
+ 'showDots' => 'boolean',
+ 'gradient' => 'boolean',
+ 'opacity' => 'number',
])]
public ?array $options = null,
) {}
diff --git a/src/Enums/GraphWidgetDisplay.php b/src/Enums/GraphWidgetDisplay.php
index d185cfd11..d95193f4e 100644
--- a/src/Enums/GraphWidgetDisplay.php
+++ b/src/Enums/GraphWidgetDisplay.php
@@ -7,4 +7,5 @@ enum GraphWidgetDisplay: string
case Bar = 'bar';
case Line = 'line';
case Pie = 'pie';
+ case Area = 'area';
}
diff --git a/tests/Unit/Dashboard/SharpDashboardTest.php b/tests/Unit/Dashboard/SharpDashboardTest.php
index 2564ef4c2..785c28257 100644
--- a/tests/Unit/Dashboard/SharpDashboardTest.php
+++ b/tests/Unit/Dashboard/SharpDashboardTest.php
@@ -33,7 +33,8 @@ protected function buildWidgets(WidgetsContainer $widgetsContainer): void
'ratioY' => 9,
'minimal' => false,
'showLegend' => true,
- 'dateLabels' => false,
+ 'displayHorizontalAxisAsTimeline' => false,
+ 'enableHorizontalAxisLabelSampling' => false,
'options' => [
'horizontal' => false,
],
diff --git a/tests/Unit/Dashboard/Widgets/SharpBarGraphWidgetTest.php b/tests/Unit/Dashboard/Widgets/SharpBarGraphWidgetTest.php
index 51f753d51..29b77abee 100644
--- a/tests/Unit/Dashboard/Widgets/SharpBarGraphWidgetTest.php
+++ b/tests/Unit/Dashboard/Widgets/SharpBarGraphWidgetTest.php
@@ -48,7 +48,7 @@
$widget = SharpBarGraphWidget::make('name')
->setDisplayHorizontalAxisAsTimeline();
- expect($widget->toArray()['dateLabels'])->toBeTrue();
+ expect($widget->toArray()['displayHorizontalAxisAsTimeline'])->toBeTrue();
});
it('allows to define horizontal option attribute', function () {
diff --git a/vite.config.ts b/vite.config.ts
index ff885b7fd..975c8613d 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -23,6 +23,8 @@ export default defineConfig(({ mode, command }) => {
alias: {
'vue': 'vue/dist/vue.esm-bundler.js',
'ziggy-js': path.resolve(__dirname, 'vendor/tightenco/ziggy'),
+ // '@unovis/vue': path.resolve(__dirname, '../unovis/packages/vue/src'),
+ // '@unovis/ts': path.resolve(__dirname, '../unovis/packages/ts'),
// ...rekaAliases()
},
// preserveSymlinks: true,
@@ -39,6 +41,7 @@ export default defineConfig(({ mode, command }) => {
// './resources/css/app.css',
],
},
+ // watch: { usePolling: true }
},
plugins: [
// circleDependency(),