vbv/client/src/components/learningPath/CircleDiagram.vue

274 lines
7.4 KiB
Vue

<script setup lang="ts">
import { useCircleStore } from "@/stores/circle";
import type { DefaultArcObject } from "d3";
import * as d3 from "d3";
import pick from "lodash/pick";
import * as log from "loglevel";
import { computed, onMounted } from "vue";
import { showIcon } from "./learningSequenceUtils";
// @ts-ignore
import colors from "@/colors.json";
import type { LearningSequence } from "@/types";
const circleStore = useCircleStore();
function someFinished(learningSequence: LearningSequence) {
if (circleStore.circle) {
return circleStore.circle.someFinishedInLearningSequence(
learningSequence.translation_key
);
}
return false;
}
function allFinished(learningSequence: LearningSequence) {
if (circleStore.circle) {
return circleStore.circle.allFinishedInLearningSequence(
learningSequence.translation_key
);
}
return false;
}
onMounted(async () => {
log.debug("CircleDiagram mounted");
render();
});
interface CirclePie extends d3.PieArcDatum<number> {
title: string;
icon: string;
slug: string;
translation_key: string;
arrowStartAngle: number;
arrowEndAngle: number;
someFinished: boolean;
allFinished: boolean;
}
const pieData = computed(() => {
const circle = circleStore.circle;
if (circle) {
const pieWeights = new Array(Math.max(circle.learningSequences.length, 1)).fill(1);
const pieGenerator = d3.pie();
const angles = pieGenerator(pieWeights);
let result = angles.map((angle) => {
const thisLearningSequence = circle.learningSequences[angle.index];
// Rotate the cirlce by PI (180 degrees) normally 0 = 12'o clock, now start at 6 o clock
angle.startAngle += Math.PI;
angle.endAngle += Math.PI;
return Object.assign(
{
startAngle: angle.startAngle,
endAngle: angle.endAngle,
...pick(thisLearningSequence, ["title", "icon", "translation_key", "slug"]),
arrowStartAngle: angle.endAngle + (angle.startAngle - angle.endAngle) / 2,
arrowEndAngle: angle.startAngle + (angle.startAngle - angle.endAngle) / 2,
someFinished: someFinished(thisLearningSequence),
allFinished: allFinished(thisLearningSequence),
},
angle
);
});
result = result.reverse();
return result as CirclePie[];
}
return undefined;
});
const width = 450;
const height = 450;
const radius = Math.min(width, height) / 2.4;
function getColor(d: CirclePie) {
let color = colors.gray[300];
if (d.someFinished) {
color = colors.sky[500];
}
if (d.allFinished) {
color = colors.green[500];
}
return color;
}
function getHoverColor(d: CirclePie) {
let color = colors.gray[200];
if (d.someFinished) {
color = colors.sky[400];
}
if (d.allFinished) {
color = colors.green[400];
}
return color;
}
function render() {
const svg = d3.select(".circle-visualization");
// Clean svg before adding new stuff.
svg.selectAll("*").remove();
if (pieData.value) {
const arrowStrokeWidth = 2;
// Append marker as definition to the svg
svg
.attr("viewBox", `0 0 ${width} ${height}`)
.append("svg:defs")
.append("svg:marker")
.attr("id", "triangle")
.attr("refX", 11)
.attr("refY", 11)
.attr("markerWidth", 20)
.attr("markerHeight", 20)
.attr("markerUnits", "userSpaceOnUse")
.attr("orient", "auto")
.append("path")
.attr("d", "M -1 0 l 10 0 M 0 -1 l 0 10")
.attr("transform", "rotate(-90, 10, 0)")
.attr("stroke-width", arrowStrokeWidth)
.attr("stroke", colors.gray[500]);
const g = svg
.append("g")
.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");
// Generate the pie diagram wede
const wedgeGenerator = d3
.arc()
.innerRadius(radius / 2.5)
.padAngle(12 / 360)
.outerRadius(radius);
// Generate the arrows
const arrowRadius = radius * 1.1;
const learningSequences = g
.selectAll(".learningSegmentArc")
.data(pieData.value)
.enter()
.append("g")
.attr("class", "learningSegmentArc")
.attr("role", "button")
.attr("fill", colors.gray[300]);
learningSequences
.on("mouseover", function (d, i) {
d3.select(this)
.transition()
.duration(200)
.attr("fill", (d) => {
// @ts-ignore
return getHoverColor(d);
});
})
.on("mouseout", function (d, i) {
d3.select(this)
.transition()
.duration(200)
.attr("fill", (d) => {
// @ts-ignore
return getColor(d);
});
})
.on("click", function (d, elm) {
log.info("clicked on ", d, elm);
document.getElementById(elm.slug)?.scrollIntoView({ behavior: "smooth" });
});
learningSequences
.transition()
.duration(1)
.attr("fill", (d) => {
return getColor(d);
});
// @ts-ignore
learningSequences.append("path").attr("d", wedgeGenerator);
const learningSequenceText = learningSequences
.append("text")
.attr("fill", colors.blue[900])
.style("font-size", "15px")
.text((d) => {
return d.title;
})
.attr("transform", function (d) {
let translate = wedgeGenerator.centroid(d as unknown as DefaultArcObject);
translate = [translate[0], translate[1] + 20];
return "translate(" + translate + ")";
})
.attr("class", "circlesText text-large font-bold")
.style("text-anchor", "middle");
const iconWidth = 25;
const learningSequenceIcon = learningSequences
.append("svg:image")
.attr("xlink:href", (d) => {
if (showIcon(d.icon)) {
return "/static/icons/" + d.icon.replace("it-", "") + ".svg";
}
return "";
})
.attr("width", iconWidth)
.attr("height", iconWidth)
.attr("transform", function (d) {
let translate = wedgeGenerator.centroid(d as unknown as DefaultArcObject);
translate = [translate[0] - iconWidth / 2, translate[1] - iconWidth];
return "translate(" + translate + ")";
})
.attr("class", "filter-blue-900");
// Create Arrows
const arrow = d3
.arc()
.innerRadius(arrowRadius)
.outerRadius(arrowRadius + arrowStrokeWidth)
.padAngle(20 / 360)
.startAngle((d) => {
return (d as unknown as CirclePie).arrowStartAngle;
})
.endAngle((d) => {
return (d as unknown as CirclePie).arrowEndAngle;
});
const arrows = g
.selectAll(".arrow")
.data(pieData.value)
.join("g")
.attr("class", "arrow")
.attr("marker-end", "url(#triangle)");
// remove last arrow
d3.selection.prototype.last = function () {
const last = this.size() - 1;
return d3.select(this.nodes()[last]);
};
const all_arows = g.selectAll(".arrow");
// @ts-ignore
all_arows.last().remove();
//Draw arrow paths
// @ts-ignore
arrows.append("path").attr("fill", colors.gray[500]).attr("d", arrow);
}
return svg;
}
</script>
<template>
<div class="svg-container h-full content-center">
<pre hidden>{{ pieData }}</pre>
<pre hidden>{{ render() }}</pre>
<svg class="circle-visualization h-full">
<circle :cx="width / 2" :cy="height / 2" :r="radius" :color="colors.gray[300]" />
<circle :cx="width / 2" :cy="height / 2" :r="radius / 2.5" color="white" />
</svg>
</div>
</template>