Skip to content

Commit 108c320

Browse files
committed
Improvement - VueUiDag - Add optional animated edges; add optional background pattern
1 parent e69efcb commit 108c320

File tree

6 files changed

+281
-11
lines changed

6 files changed

+281
-11
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ From the dataset you pass into the props, this component will produce the most a
456456
| `VueUiChestnut` | `VueUiChestnutDatasetRoot[]` | `VueUiChestnutConfig` | `@selectRoot`, `@selectBranch`, `@selectNut`, `getData`, `generatePdf`, `generateCsv`, `generateImage`, `toggleTable`, `getImage` | `#svg`, `#legend`, `#watermark`, `#chart-background` |||
457457
| `VueUiChord` | `VueUiChordDataset` | `VueUiChordConfig` | `@selectLegend`, `@selectGroup`, `@selectRibbon`, `getData`, `generatePdf`, `generateCsv`, `generateImage`, `toggleTable`, `getImage` | `#svg`, `#legend`, `#watermark`, `#chart-background`, `#pattern` |||
458458
| `VueUiCirclePack` | `VueUiCirclePackDatasetItem[]` | `VueUiCirclePackConfig` | `@selectDatapoint`, `getData`, `generatePdf`, `generateImage`, `generateCsv`, `toggleTable`, `getImage` | `#svg`, `#legend`, `#watermark`, `#chart-background` , `#pattern`, `#zoom-label`, `#data-label`, `#tooltip-before`, `#tooltip-after` |||
459-
| `VueUiDag` | `VueUiDagDataset` | `VueUiDagConfig` | `@onNodeClick`, `@onMidpointEnter`, `@onMidpointLeave`, `getData`, `getImage`, `generatePdf`, `generateImage`, `generateSvg`, `toggleAnnotator`, `toggleFullscreen`, `zoomIn`, `zoomOut`, `resetZoom`, `switchDirection` | `#svg`, `#source`, `#node-label`, `#node`, `#tooltip-midpoint`, `#tooltip-node` |||
459+
| `VueUiDag` | `VueUiDagDataset` | `VueUiDagConfig` | `@onNodeClick`, `@onMidpointEnter`, `@onMidpointLeave`, `getData`, `getImage`, `generatePdf`, `generateImage`, `generateSvg`, `toggleAnnotator`, `toggleFullscreen`, `zoomIn`, `zoomOut`, `resetZoom`, `switchDirection` | `#svg`, `#source`, `#node-label`, `#node`, `#tooltip-midpoint`, `#tooltip-node`, `#background-pattern` |||
460460
| `VueUiDonutEvolution` | `VueUiDonutEvolutionDatasetItem[]` | `VueUiDonutEvolutionConfig` | `@selectLegend`, `hideSeries`, `showSeries`, `getData`, `generatePdf`, `generateCsv`, `generateImage`, `toggleTable` , `getImage` | `#svg`, `#legend`, `#reset-action`, `#watermark`, `#chart-background` |||
461461
| `VueUiDonut` | `VueUiDonutDatasetItem[]` | `VueUiDonutConfig` | `@selectDatapoint`, `@selectLegend`, `hideSeries`, `showSeries`, `getData`, `generatePdf`, `generateCsv`, `generateImage`, `toggleTable`, `toggleLabels`, `toggleTooltip`, `getImage` | `#svg`, `#legend`, `#dataLabel`, `#tooltip-before`, `#tooltip-after`, `#plot-comment`, `#watermark`, `#chart-background`, `#pattern` |||
462462
| `VueUiDumbbell` | `VueUiDumbbellDataset[]` | `VueUiDumbbellConfig` | `getData`, `generatePdf`, `generateCsv`, `generateImage`, `toggleTable`, `getImage` | `#svg`, `#legend`, `#watermark`, `#chart-background` |||

TestingArena/ArenaVueUiDag.vue

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,30 @@ const dataset = ref({
3232
{ id: "D", label: "DDD" },
3333
],
3434
edges: [
35-
{ from: "A", to: "B", color: '#FF0000'},
35+
{
36+
from: "A",
37+
to: "B",
38+
color: '#FF0000',
39+
animated: true,
40+
dasharray: '2 6',
41+
animationDurationMs: 1000,
42+
animationDirection: -1 // force direction
43+
},
3644
{ from: "B", to: "A" },
3745
{ from: "B", to: "A" },
3846
{ from: "C", to: "A" },
3947
{ from: "B", to: "D" },
4048
{ from: "C", to: "D" },
41-
{ from: "D", to: "A" },
49+
{ from: "D", to: "A", animated: true },
4250
]
4351
});
4452
53+
// onMounted(() => {
54+
// setTimeout(() => {
55+
// dataset.value.edges.at(-1).animated = false
56+
// }, 2000)
57+
// })
58+
4559
// const dataset = ref(undefined);
4660
4761
// onMounted(() => {
@@ -81,6 +95,12 @@ const model = createModel([
8195
COLOR('style.chart.backgroundColor', { def: '#FFFFFF' }),
8296
COLOR('style.chart.color', { def: '#1A1A1A' }),
8397
98+
CHECKBOX('style.chart.backgroundPattern.show', { def: true }),
99+
RANGE('style.chart.backgroundPattern.spacingRatio', { def: 3, min: 1, max: 5, step: 0.1}),
100+
RANGE('style.chart.backgroundPattern.dotRadiusRatio', { def: 5, min: 1, max: 12, step: 1}),
101+
COLOR('style.chart.backgroundPattern.dotColor', { def: '#E1E5E8' }),
102+
RANGE('style.chart.backgroundPattern.opacity', { def: 1, min: 0, max: 1, step: 0.1 }),
103+
84104
SELECT('style.chart.layout.rankDirection', ['TB', 'RL', 'BT', 'LR'], { def: 'TB'}),
85105
SELECT('style.chart.layout.rankSeparation', [100, 80, 60, 40, 20, 0], { def: 60} ),
86106
SELECT('style.chart.layout.nodeSeparation', [100, 75, 50, 25, 0], { def: 50}),
@@ -204,7 +224,12 @@ const configTheme = computed(() => ({
204224
<LocalVueUiDag :dataset="dataset" :config="{
205225
...config,
206226
responsive: true
207-
}"/>
227+
}">
228+
<!-- <template #background-pattern="{ x, y, color }">
229+
<line :x1="x - 2" :x2="x + 2" :y1="y" :y2="y" :stroke="color" stroke-width="0.5"/>
230+
<line :x1="x" :x2="x" :y1="y - 2" :y2="y + 2" :stroke="color" stroke-width="0.5"/>
231+
</template> -->
232+
</LocalVueUiDag>
208233
</div>
209234
</template>
210235

src/components/vue-ui-dag.vue

Lines changed: 205 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,159 @@ function setupResponsive() {
697697
handleResize();
698698
}
699699
700+
function getIdealDashoffsetDelta(pathElement, options = {}) {
701+
const {
702+
direction = -1,
703+
mode = "oneLapNearest",
704+
dasharray = null
705+
} = options;
706+
707+
if (!pathElement || typeof pathElement.getTotalLength !== "function") {
708+
if (debug.value) {
709+
console.warn(
710+
"VueUiDag @getIdealDashoffsetDelta: invalid path element",
711+
pathElement
712+
);
713+
}
714+
return 0;
715+
}
716+
717+
const pathLength = pathElement.getTotalLength();
718+
719+
const dasharrayValue =
720+
dasharray ??
721+
pathElement.getAttribute("stroke-dasharray") ??
722+
(typeof getComputedStyle === "function"
723+
? getComputedStyle(pathElement).strokeDasharray
724+
: "");
725+
726+
const patternLength = sumDasharray(dasharrayValue);
727+
728+
if (!Number.isFinite(patternLength) || patternLength <= 0) {
729+
return direction * pathLength;
730+
}
731+
732+
const nearestMultiple = Math.max(1, Math.round(pathLength / patternLength));
733+
const ceilMultiple = Math.max(1, Math.ceil(pathLength / patternLength));
734+
const floorMultiple = Math.max(1, Math.floor(pathLength / patternLength));
735+
736+
let delta;
737+
if (mode === "pattern") delta = patternLength;
738+
else if (mode === "oneLapCeil") delta = ceilMultiple * patternLength;
739+
else if (mode === "oneLapFloor") delta = floorMultiple * patternLength;
740+
else delta = nearestMultiple * patternLength;
741+
742+
return direction * delta;
743+
744+
function sumDasharray(value) {
745+
if (!value || value === "none") return NaN;
746+
747+
const numbers = String(value)
748+
.replace(/,/g, " ")
749+
.trim()
750+
.split(/\s+/)
751+
.map((token) => Number.parseFloat(token))
752+
.filter((number) => Number.isFinite(number));
753+
754+
if (!numbers.length) return NaN;
755+
756+
const sum = numbers.reduce((accumulator, current) => accumulator + current, 0);
757+
return numbers.length % 2 === 1 ? sum * 2 : sum;
758+
}
759+
}
760+
761+
const edgePathElementByIdentifier = ref(new Map());
762+
const edgeAnimationByIdentifier = ref(new Map());
763+
764+
function registerEdgePathElement(edgeIdentifier) {
765+
return function register(element) {
766+
if (element) {
767+
edgePathElementByIdentifier.value.set(edgeIdentifier, element);
768+
} else {
769+
edgePathElementByIdentifier.value.delete(edgeIdentifier);
770+
}
771+
};
772+
}
773+
774+
function stopAllEdgeAnimations() {
775+
edgeAnimationByIdentifier.value.forEach((animationHandle) => {
776+
try {
777+
animationHandle.cancel();
778+
} catch {
779+
// whatever
780+
}
781+
});
782+
edgeAnimationByIdentifier.value.clear();
783+
}
784+
785+
function startEdgeAnimations() {
786+
stopAllEdgeAnimations();
787+
788+
const edges = layoutData.value?.edges ?? [];
789+
if (!edges.length) return;
790+
791+
const animationDefaults = FINAL_CONFIG.value.style.chart.edges.animations;
792+
793+
const referenceDistance =
794+
Number(animationDefaults.referenceDistance) > 0
795+
? Number(animationDefaults.referenceDistance)
796+
: 24;
797+
798+
edges.forEach((edge) => {
799+
const isAnimated = !!edge?.original?.animated;
800+
if (!isAnimated) return;
801+
802+
const pathElement = edgePathElementByIdentifier.value.get(edge.id);
803+
if (!pathElement) return;
804+
805+
const dasharrayValue = edge?.original?.dasharray ?? animationDefaults.dasharray;
806+
807+
pathElement.style.strokeDasharray = String(dasharrayValue);
808+
pathElement.style.strokeDashoffset = "0";
809+
810+
const hasEdgeDirection = ![undefined, null].includes(edge?.original?.animationDirection);
811+
const hasConfigDirection = ![undefined, null].includes(animationDefaults.animationDirection);
812+
813+
const direction = hasEdgeDirection
814+
? Number(edge.original.animationDirection)
815+
: hasConfigDirection
816+
? Number(animationDefaults.animationDirection)
817+
: -1;
818+
819+
const dashoffsetDelta = getIdealDashoffsetDelta(pathElement, {
820+
direction,
821+
mode: "oneLapNearest",
822+
dasharray: String(dasharrayValue)
823+
});
824+
825+
// Duration is derived from speed (referenceDistance / durationReferenceMs)
826+
const durationReferenceMsRaw = edge?.original?.animationDurationMs ?? animationDefaults.animationDurationMs ?? 1000;
827+
828+
const durationReferenceMs = Number(durationReferenceMsRaw);
829+
const hasDurationReference = Number.isFinite(durationReferenceMs) && durationReferenceMs > 0;
830+
const speedUnitsPerMs = hasDurationReference ? referenceDistance / durationReferenceMs : referenceDistance / 1000;
831+
const durationMilliseconds = Math.max(1, Math.round(Math.abs(dashoffsetDelta) / Math.max(1e-9, speedUnitsPerMs)));
832+
833+
const animationHandle = pathElement.animate(
834+
[{ strokeDashoffset: 0 }, { strokeDashoffset: dashoffsetDelta }],
835+
{ duration: durationMilliseconds, iterations: Infinity, easing: "linear" }
836+
);
837+
838+
edgeAnimationByIdentifier.value.set(edge.id, animationHandle);
839+
});
840+
}
841+
842+
watch(() => layoutData.value && layoutData.value.edges, async () => {
843+
await nextTick();
844+
startEdgeAnimations();
845+
},
846+
{ deep: true, immediate: true }
847+
);
848+
849+
onBeforeUnmount(() => {
850+
stopAllEdgeAnimations();
851+
});
852+
700853
async function getImage({ scale = 2} = {}) {
701854
if (!dagChart.value) return
702855
const { width, height } = dagChart.value.getBoundingClientRect()
@@ -712,6 +865,17 @@ async function getImage({ scale = 2} = {}) {
712865
}
713866
}
714867
868+
const backgroundPatternGridSpacing = computed(() => {
869+
const nodeHeight = Number(FINAL_CONFIG.value.style.chart.layout.nodeHeight);
870+
return Number.isFinite(nodeHeight) && nodeHeight > 0
871+
? nodeHeight / FINAL_CONFIG.value.style.chart.backgroundPattern.spacingRatio
872+
: 12;
873+
});
874+
875+
const backgroundPatternDotRadius = computed(() => {
876+
return backgroundPatternGridSpacing.value * (FINAL_CONFIG.value.style.chart.backgroundPattern.dotRadiusRatio / 100);
877+
});
878+
715879
function getData() {
716880
return layoutData.value;
717881
}
@@ -731,7 +895,6 @@ defineExpose({
731895
})
732896
</script>
733897
734-
735898
<template>
736899
<div
737900
:class="`vue-data-ui-component vue-ui-dag ${isFullscreen ? 'vue-data-ui-wrapper-fullscreen' : ''} ${FINAL_CONFIG.responsive ? 'vue-ui-dag-responsive' : ''}`"
@@ -881,6 +1044,41 @@ defineExpose({
8811044
>
8821045
<PackageVersion />
8831046
1047+
<defs v-if="FINAL_CONFIG.style.chart.backgroundPattern.show">
1048+
<pattern
1049+
:id="`dag_bg_pattern_${uid}`"
1050+
patternUnits="userSpaceOnUse"
1051+
:width="backgroundPatternGridSpacing"
1052+
:height="backgroundPatternGridSpacing"
1053+
>
1054+
<slot name="background-pattern" v-bind="{
1055+
x: backgroundPatternGridSpacing / 2,
1056+
y: backgroundPatternGridSpacing / 2,
1057+
color: FINAL_CONFIG.style.chart.backgroundPattern.dotColor
1058+
}">
1059+
<circle
1060+
:cx="backgroundPatternGridSpacing / 2"
1061+
:cy="backgroundPatternGridSpacing / 2"
1062+
:r="backgroundPatternDotRadius"
1063+
:fill="FINAL_CONFIG.style.chart.backgroundPattern.dotColor"
1064+
/>
1065+
</slot>
1066+
</pattern>
1067+
</defs>
1068+
1069+
<rect
1070+
v-if="FINAL_CONFIG.style.chart.backgroundPattern.show"
1071+
:x="panZoomViewBox?.x ?? 0"
1072+
:y="panZoomViewBox?.y ?? 0"
1073+
:width="panZoomViewBox?.width ?? 0"
1074+
:height="panZoomViewBox?.height ?? 0"
1075+
:fill="`url(#dag_bg_pattern_${uid})`"
1076+
:style="{
1077+
pointerEvents: 'none',
1078+
opacity: FINAL_CONFIG.style.chart.backgroundPattern.opacity
1079+
}"
1080+
/>
1081+
8841082
<!-- Arrow marker. Hidden when arrowShape is "undirected". -->
8851083
<defs v-if="layoutData.arrowShape !== 'undirected'">
8861084
<template v-for="color in edgeColors" :key="color">
@@ -919,12 +1117,13 @@ defineExpose({
9191117
<template v-for="edge in layoutData.edges" :key="edge.id">
9201118
<path
9211119
data-cy-edge
922-
:d="edge.pathData"
923-
fill="none"
1120+
:ref="registerEdgePathElement(edge.id)"
1121+
:d="edge.pathData"
1122+
fill="none"
9241123
:stroke="edge.original.color ?? FINAL_CONFIG.style.chart.edges.stroke"
925-
:stroke-width="FINAL_CONFIG.style.chart.edges.strokeWidth * ((edge.from === highlightedNodeId || edge.id === tooltipEdge?.id) ? 2 : 1)"
926-
stroke-linecap="round"
927-
stroke-linejoin="round"
1124+
:stroke-width="FINAL_CONFIG.style.chart.edges.strokeWidth * ((edge.from === highlightedNodeId || edge.id === tooltipEdge?.id) ? 2 : 1)"
1125+
stroke-linecap="round"
1126+
stroke-linejoin="round"
9281127
style="pointer-events: none; transition: stroke-width 0.2s ease-in-out"
9291128
/>
9301129
<circle

src/themes/vue_ui_dag.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
"chart": {
66
"backgroundColor": "#1A1A1A",
77
"color": "#CCCCCC",
8+
"backgroundPattern": {
9+
"dotColor": "#3A3A3A"
10+
},
811
"nodes": {
912
"stroke": "#6A6A6A",
1013
"backgroundColor": "#2A2A2A",
@@ -47,6 +50,9 @@
4750
"chart": {
4851
"backgroundColor": "#FFF8E1",
4952
"color": "#424242",
53+
"backgroundPattern": {
54+
"dotColor": "#edc7bb"
55+
},
5056
"nodes": {
5157
"stroke": "#5D4037",
5258
"backgroundColor": "#fcf9f0",
@@ -89,6 +95,9 @@
8995
"chart": {
9096
"backgroundColor": "#1E1E1E",
9197
"color": "#BDBDBD",
98+
"backgroundPattern": {
99+
"dotColor": "#3E3E3E"
100+
},
92101
"nodes": {
93102
"stroke": "#965039",
94103
"backgroundColor": "#2A2A2A",
@@ -131,6 +140,9 @@
131140
"chart": {
132141
"backgroundColor": "#1A1A1A",
133142
"color": "#99AA99",
143+
"backgroundPattern": {
144+
"dotColor": "#2f3d2f"
145+
},
134146
"nodes": {
135147
"stroke": "#66CC66",
136148
"backgroundColor": "#2A2A2A",
@@ -173,6 +185,9 @@
173185
"chart": {
174186
"backgroundColor": "#fbfafa",
175187
"color": "#8A9892",
188+
"backgroundPattern": {
189+
"dotColor": "#dbcfc5"
190+
},
176191
"nodes": {
177192
"stroke": "#8F837A",
178193
"backgroundColor": "#ffffff",
@@ -215,6 +230,9 @@
215230
"chart": {
216231
"backgroundColor": "#f6f6fb",
217232
"color": "#50606C",
233+
"backgroundPattern": {
234+
"dotColor": "#c3cfd9"
235+
},
218236
"layout": {
219237
"arrowShape": "normal",
220238
"curvedEdges": false

0 commit comments

Comments
 (0)