-
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+ Graph
+
+
+
+
+
+
+
+
+
+
-
+
@@ -134,11 +147,13 @@
import { defineComponent } from 'vue';
import { SignedIn, SignedOut, SignInButton, useAuth, UserButton } from '@clerk/vue'
import { SemesterInfo } from "../helpers/semester-info";
-import ToggleDarkMode from './ToggleDarkMode.vue';
+import NavigationToggleDarkMode from './NavigationToggleDarkMode.vue';
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from '@fortawesome/fontawesome-svg-core';
import { faChevronDown } from '@fortawesome/free-solid-svg-icons';
import SavedPlans from "./SavedPlans.vue";
+import NavigationToggleValidation from "./NavigationToggleValidation.vue";
+import GraphModal from '../components/Graph.vue';
library.add(faChevronDown);
@@ -151,8 +166,10 @@ export default defineComponent({
SignedOut,
SignInButton,
UserButton,
- ToggleDarkMode,
- FontAwesomeIcon
+ NavigationToggleDarkMode,
+ FontAwesomeIcon,
+ NavigationToggleValidation,
+ GraphModal
},
setup() {
const { isSignedIn } = useAuth();
@@ -163,6 +180,7 @@ export default defineComponent({
},
data() {
return {
+ showGraphModal: false,
isBurgerActive: false,
startSemesterName: SemesterInfo.latestAutumnSemester().toString(),
categories: [
diff --git a/src/components/ToggleDarkMode.vue b/src/components/NavigationToggleDarkMode.vue
similarity index 85%
rename from src/components/ToggleDarkMode.vue
rename to src/components/NavigationToggleDarkMode.vue
index 5f1ca020..b913cead 100644
--- a/src/components/ToggleDarkMode.vue
+++ b/src/components/NavigationToggleDarkMode.vue
@@ -2,8 +2,8 @@
diff --git a/src/components/NavigationToggleValidation.vue b/src/components/NavigationToggleValidation.vue
new file mode 100644
index 00000000..1aa095eb
--- /dev/null
+++ b/src/components/NavigationToggleValidation.vue
@@ -0,0 +1,52 @@
+
+
+
+
+ Validierung:
+
+
+
+
+
+
+
+
+
diff --git a/src/composables/useGraphHighlighting.ts b/src/composables/useGraphHighlighting.ts
new file mode 100644
index 00000000..6b594d93
--- /dev/null
+++ b/src/composables/useGraphHighlighting.ts
@@ -0,0 +1,83 @@
+import { computed, ref } from "vue";
+import type { Ref } from "vue";
+import type { Edge, Node } from "@vue-flow/core";
+import type { GraphEdge } from "../types/Graph";
+
+export function useGraphHighlighting(nodes: Ref, edges: Ref) {
+ const hoveredId = ref(null);
+
+ const onNodeHover = (id: string) => {
+ hoveredId.value = id;
+ };
+ const onNodeLeave = () => {
+ hoveredId.value = null;
+ };
+
+ const activeHover = computed(() => {
+ const id = hoveredId.value;
+ return id && nodes.value.some((n) => n.id === id) ? id : null;
+ });
+
+ const highlightedNodes = computed((): Set => {
+ const set = new Set();
+ const hov = activeHover.value;
+ if (!hov) return set;
+ set.add(hov);
+ edges.value.forEach((edge) => {
+ if (edge.source === hov) set.add(edge.target);
+ if (edge.target === hov) set.add(edge.source);
+ });
+ return set;
+ });
+
+ const processedEdges = computed(() => {
+ const hov = activeHover.value;
+ if (!hov) {
+ return edges.value as GraphEdge[];
+ }
+ return edges.value.map((edge) => {
+ const isHighlighted = edge.source === hov || edge.target === hov;
+ const baseStyle = edge.style || {};
+ const dimmedStyle = !isHighlighted
+ ? {
+ opacity: 0.1,
+ filter: "grayscale(100%)",
+ transition: "opacity 0.3s ease, filter 0.3s ease",
+ }
+ : { opacity: 1, filter: "none", transition: "opacity 0.3s ease, filter 0.3s ease" };
+
+ let badgeBg = {};
+ if (edge.labelShowBg && edge.labelBgStyle) {
+ badgeBg = !isHighlighted
+ ? { ...edge.labelBgStyle, fill: edge.labelBgStyle.fill + "1A" }
+ : {};
+ }
+
+ let badgeText = {};
+ if (edge.label && edge.labelStyle) {
+ badgeText = !isHighlighted
+ ? {
+ ...edge.labelStyle,
+ fill: `rgba(0, 0, 0, 0.1)`,
+ }
+ : edge.labelStyle;
+ }
+
+ return {
+ ...edge,
+ style: { ...baseStyle, ...dimmedStyle },
+ ...(edge.labelShowBg ? { labelBgStyle: { ...edge.labelBgStyle, ...badgeBg } } : {}),
+ ...(edge.label ? { labelStyle: { ...edge.labelStyle, ...badgeText } } : {}),
+ } as GraphEdge;
+ });
+ });
+
+ return {
+ hoveredId,
+ activeHover,
+ highlightedNodes,
+ processedEdges,
+ onNodeHover,
+ onNodeLeave,
+ };
+}
diff --git a/src/composables/useGraphTooltip.ts b/src/composables/useGraphTooltip.ts
new file mode 100644
index 00000000..87a625f2
--- /dev/null
+++ b/src/composables/useGraphTooltip.ts
@@ -0,0 +1,57 @@
+import { ref } from "vue";
+
+export function useTooltip() {
+ const tooltip = ref({
+ visible: false,
+ x: 20,
+ y: 20,
+ edgeId: "",
+ });
+
+ let cleanupMoveListener: (() => void) | null = null;
+
+ function showTooltip(event: MouseEvent | TouchEvent, wrapperEl: HTMLElement, edgeId: string) {
+ const target = event.target as Element;
+ const badgeRect =
+ target.tagName === "rect" && target.getAttribute("rx") ? target : target.closest("rect[rx]");
+
+ if (!badgeRect) return;
+
+ const badgeBox = (badgeRect as SVGGraphicsElement).getBoundingClientRect();
+ const wrapperBox = wrapperEl.getBoundingClientRect();
+
+ tooltip.value.x = badgeBox.left + badgeBox.width / 2 - wrapperBox.left;
+ tooltip.value.y = badgeBox.top + badgeBox.height / 2 - wrapperBox.top;
+ tooltip.value.visible = true;
+ tooltip.value.edgeId = edgeId;
+
+ const onMove = () => {
+ hideTooltip();
+ wrapperEl.removeEventListener("mousemove", onMove);
+ wrapperEl.removeEventListener("touchmove", onMove);
+ cleanupMoveListener = null;
+ };
+ wrapperEl.addEventListener("mousemove", onMove, { once: true });
+ wrapperEl.addEventListener("touchmove", onMove, { once: true });
+
+ cleanupMoveListener = () => {
+ wrapperEl.removeEventListener("mousemove", onMove);
+ wrapperEl.removeEventListener("touchmove", onMove);
+ cleanupMoveListener = null;
+ };
+ }
+
+ function hideTooltip() {
+ tooltip.value.visible = false;
+
+ if (cleanupMoveListener) {
+ cleanupMoveListener();
+ }
+ }
+
+ return {
+ tooltip,
+ showTooltip,
+ hideTooltip,
+ };
+}
diff --git a/src/composables/useGraphView.ts b/src/composables/useGraphView.ts
new file mode 100644
index 00000000..3b5157c8
--- /dev/null
+++ b/src/composables/useGraphView.ts
@@ -0,0 +1,134 @@
+import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
+import { useRoute } from "vue-router";
+import type { VueFlow } from "@vue-flow/core";
+import type { Node, Edge } from "@vue-flow/core";
+import { store } from "../helpers/store";
+import { StorageHelper } from "../helpers/storage-helper";
+import { getColorHexForPrioritizedCategory } from "../helpers/color-helper";
+import { generateModuleEdges } from "../helpers/graph/graph-edges";
+import { generateModuleNodes } from "../helpers/graph/graph-nodes";
+import { sortLayout } from "../helpers/graph/graph-layout";
+import { useTooltip } from "./useGraphTooltip";
+import { useGraphHighlighting } from "./useGraphHighlighting";
+import type { Module } from "../helpers/types";
+
+export function useGraphView() {
+ const wrapperRef = ref(null);
+ const vueFlowRef = ref | null>(null);
+
+ const laidOutNodes = ref([]);
+ const laidOutEdges = ref([]);
+
+ const modules = computed(() => store.getters.modules as Module[]);
+ const allPlannedModuleIds = computed(
+ () => store.getters.allPlannedModuleIds as string[]
+ );
+
+ const { tooltip, showTooltip, hideTooltip } = useTooltip();
+ const { hoveredId, processedEdges } = useGraphHighlighting(laidOutNodes, laidOutEdges);
+
+ const route = useRoute();
+
+ function getModuleColor(module: Module): string {
+ return getColorHexForPrioritizedCategory(module.categoriesForColoring);
+ }
+
+ function onEdgeClick({ event, edge }: { event: MouseEvent | TouchEvent; edge: Edge }) {
+ event.stopPropagation();
+ if (!edge.label) return;
+
+ const wrapperEl = wrapperRef.value!;
+ showTooltip(event, wrapperEl, edge.id!);
+ }
+
+ function onWrapperLeave() {
+ hoveredId.value = null;
+ hideTooltip();
+ }
+
+ function fitView() {
+ vueFlowRef.value?.fitView();
+ }
+
+ async function computeLayout() {
+ const plannedModules = modules.value.filter((m) => allPlannedModuleIds.value.includes(m.id));
+ const rawNodes = generateModuleNodes(plannedModules);
+ const rawEdges = generateModuleEdges(
+ plannedModules,
+ true,
+ allPlannedModuleIds.value,
+ getModuleColor
+ );
+ try {
+ const { nodes, edges } = await sortLayout(rawNodes, rawEdges);
+ laidOutNodes.value = nodes;
+ laidOutEdges.value = edges;
+ } catch (err) {
+ console.error("layout error:", err);
+ }
+ }
+
+ function getPlanDataFromUrl() {
+ const [semesters, accreditedModules, , validationEnabled] = StorageHelper.getDataFromUrlHash(
+ window.location.hash,
+ (semNum: number, moduleId: string) => {
+ console.error(`Unknown module ${moduleId} encountered for semester ${semNum}`);
+ }
+ );
+
+ store.commit("setValidationEnabled", validationEnabled);
+ store.commit("setSemesters", semesters);
+ store.commit("setAccreditedModules", accreditedModules);
+ }
+
+ function loadPlanDataFromUrl() {
+ getPlanDataFromUrl();
+ computeLayout();
+ }
+
+ function handleKeydown(event: KeyboardEvent) {
+ if (event.key.toLowerCase() === "f") fitView();
+ }
+
+ function setGraphHeight() {
+ const wrapper = wrapperRef.value;
+ if (!wrapper) return;
+ const { top } = wrapper.getBoundingClientRect();
+ wrapper.style.height = `${window.innerHeight - top}px`;
+ }
+
+ onMounted(() => {
+ store.dispatch("loadModules").then(loadPlanDataFromUrl);
+ window.addEventListener("keydown", handleKeydown);
+ window.addEventListener("resize", setGraphHeight);
+ setGraphHeight();
+ });
+
+ onBeforeUnmount(() => {
+ window.removeEventListener("keydown", handleKeydown);
+ window.removeEventListener("resize", setGraphHeight);
+ });
+
+ watch([modules, allPlannedModuleIds], ([mods, ids]) => {
+ if (mods.length && ids.length) {
+ computeLayout();
+ } else {
+ laidOutNodes.value = [];
+ laidOutEdges.value = [];
+ }
+});
+ watch(() => route.hash, loadPlanDataFromUrl);
+
+ return {
+ wrapperRef,
+ vueFlowRef,
+ nodes: laidOutNodes,
+ edges: processedEdges,
+ tooltipVisible: computed(() => tooltip.value.visible),
+ tooltipX: computed(() => tooltip.value.x),
+ tooltipY: computed(() => tooltip.value.y),
+ onEdgeClick,
+ onWrapperLeave,
+ fitView,
+ };
+}
diff --git a/src/helpers/color-helper.ts b/src/helpers/color-helper.ts
index a97b4f52..9bc58128 100644
--- a/src/helpers/color-helper.ts
+++ b/src/helpers/color-helper.ts
@@ -11,6 +11,20 @@ const CATEGORY_COLOR_CLASS_MAP: { [key: string]: string } = {
Fallback: 'bg-purple-500',
};
+const CATEGORY_COLOR_HEX_MAP: { [key: string]: string } = {
+ Auf: '#737373',
+ MaPh: '#075985',
+ KomEng: '#38bdf8',
+ gwr: '#059669',
+ GWRIKTS: '#14b8a6',
+ Inf: '#475569',
+ SaBa: '#1e293b',
+ EP: '#1e293b',
+ RA: '#f59e0b',
+ Fallback: '#8b5cf6',
+};
+
+
type ColorClassCategoryKey = keyof typeof CATEGORY_COLOR_CLASS_MAP;
const CATEGORY_COLOR_PRIORITIES: { [key: ColorClassCategoryKey]: number } = {
@@ -29,7 +43,25 @@ const getColorClassForPrioritizedCategory = (categoryIds: string[]) => {
return prioritzedCategory ? getColorClassForCategoryId(prioritzedCategory.id) : CATEGORY_COLOR_CLASS_MAP.Fallback;
};
+
+const getColorHexForCategoryId = (categoryId: string): string =>
+ CATEGORY_COLOR_HEX_MAP[categoryId] || CATEGORY_COLOR_HEX_MAP.Fallback;
+
+const getColorHexForPrioritizedCategory = (categoryIds: string[]): string => {
+ const prioritizedCategory = categoryIds
+ .map((categoryId) => ({
+ id: categoryId,
+ priority: CATEGORY_COLOR_PRIORITIES[categoryId as keyof typeof CATEGORY_COLOR_CLASS_MAP] ?? 0,
+ }))
+ .sort((a, b) =>
+ b.priority === a.priority ? (a.id > b.id ? 1 : -1) : b.priority - a.priority
+ )[0];
+
+ return prioritizedCategory ? getColorHexForCategoryId(prioritizedCategory.id) : CATEGORY_COLOR_HEX_MAP.Fallback;
+};
+
export {
getColorClassForCategoryId,
getColorClassForPrioritizedCategory,
+ getColorHexForPrioritizedCategory,
};
diff --git a/src/helpers/graph/graph-edges.ts b/src/helpers/graph/graph-edges.ts
new file mode 100644
index 00000000..a804b66d
--- /dev/null
+++ b/src/helpers/graph/graph-edges.ts
@@ -0,0 +1,82 @@
+import type { Edge } from "@vue-flow/core";
+import { MarkerType } from "@vue-flow/core";
+import type { Module } from "../types";
+import type { GraphEdge } from "../../types/Graph";
+
+function generateGraphEdges(edge: Edge, sourceColor: string, targetColor: string): GraphEdge {
+ const gradientId = `edgeGradient_${edge.source}_${edge.target}`;
+
+ return {
+ ...edge,
+ gradientId,
+ sourceColor,
+ targetColor,
+ } as GraphEdge;
+}
+
+function mandatoryEdgeStyle(edge: GraphEdge, _sourceColor: string, targetColor: string): GraphEdge {
+ const midColor = targetColor;
+
+ edge.style = { ...edge.style, strokeWidth: 4 };
+ edge.label = "!";
+ edge.labelShowBg = true;
+ edge.labelBgStyle = {
+ fill: midColor,
+ width: "30px",
+ height: "30px",
+ transform: "translate(-6px,2px)",
+ // Firefox was not happy about width: 30, but the type must be a number - so thiw is a workaround
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any;
+ edge.labelBgBorderRadius = 15;
+ edge.labelBgPadding = [6, 6];
+ edge.labelStyle = {
+ fill: "#fff",
+ fontWeight: "bold",
+ fontSize: "20px",
+ lineHeight: "14px",
+ };
+ return edge;
+}
+
+function generateModuleEdges(
+ modules: Module[],
+ showAll: boolean,
+ plannedIds: string[],
+ getColor: (m: Module) => string
+): GraphEdge[] {
+ const edges: GraphEdge[] = [];
+
+ modules.forEach((module) => {
+ module.dependentModuleIds?.forEach((depId) => {
+ if (!showAll && !plannedIds.includes(depId)) return;
+ const targetModule = modules.find((m) => m.id === depId);
+ if (!targetModule) return;
+
+ const sourceColor = getColor(module);
+ const targetColor = getColor(targetModule);
+ const gradientId = `edgeGradient_${module.id}_${depId}`;
+
+ let edge: GraphEdge = {
+ id: `${module.id}->${depId}`,
+ source: module.id,
+ target: depId,
+ markerEnd: { type: MarkerType.ArrowClosed, color: targetColor },
+ style: { strokeWidth: 4, stroke: `url(#${gradientId})` },
+ gradientId,
+ sourceColor,
+ targetColor,
+ } as GraphEdge;
+
+ if (module.isMandatory && targetModule.isMandatory) {
+ edge = mandatoryEdgeStyle(edge, sourceColor, targetColor);
+ }
+
+ edges.push(edge);
+ });
+ });
+
+ return edges;
+}
+
+export { generateGraphEdges, mandatoryEdgeStyle, generateModuleEdges };
diff --git a/src/helpers/graph/graph-layout-config.ts b/src/helpers/graph/graph-layout-config.ts
new file mode 100644
index 00000000..605a3546
--- /dev/null
+++ b/src/helpers/graph/graph-layout-config.ts
@@ -0,0 +1,20 @@
+export const LayoutConfig = {
+ elkOptions: {
+ algorithm: "layered",
+ direction: "RIGHT",
+ nodeSpacing: "5",
+ layeringStrategy: "INTERACTIVE",
+ crossingMinStrategy: "INTERACTIVE",
+ nodePlacementStrategy: "NETWORK_SIMPLEX",
+ randomize: "false",
+ spacingNodeNodeBetweenLayers: "250",
+ },
+ nodeSize: {
+ width: 320,
+ height: 100,
+ },
+ nodeGap: { // for unconnected nodes
+ width: 400,
+ height: 120,
+ },
+};
diff --git a/src/helpers/graph/graph-layout-utils.ts b/src/helpers/graph/graph-layout-utils.ts
new file mode 100644
index 00000000..e8e72b69
--- /dev/null
+++ b/src/helpers/graph/graph-layout-utils.ts
@@ -0,0 +1,44 @@
+import type { GraphNode } from "../../types/Graph";
+import type { GraphEdge } from "../../types/Graph";
+
+function isIsolated(id: string, edges: GraphEdge[]): boolean {
+ return !edges.some((e) => e.source === id || e.target === id);
+}
+
+function buildAdjacency(edges: GraphEdge[]): Map> {
+ const adj = new Map>();
+ edges.forEach((e) => {
+ if (!adj.has(e.source)) adj.set(e.source, new Set());
+ if (!adj.has(e.target)) adj.set(e.target, new Set());
+ (adj.get(e.source) as Set).add(e.target);
+ (adj.get(e.target) as Set).add(e.source);
+ });
+ return adj;
+}
+
+function components(nodes: GraphNode[], edges: GraphEdge[]): string[][] {
+ const adj = buildAdjacency(edges);
+ const seen = new Set();
+ const comps: string[][] = [];
+
+ for (const n of nodes) {
+ if (seen.has(n.id)) continue;
+ const stack = [n.id];
+ const comp: string[] = [];
+ seen.add(n.id);
+ while (stack.length) {
+ const cur = stack.pop()!;
+ comp.push(cur);
+ for (const neigh of adj.get(cur) ?? []) {
+ if (!seen.has(neigh)) {
+ seen.add(neigh);
+ stack.push(neigh);
+ }
+ }
+ }
+ comps.push(comp);
+ }
+ return comps;
+}
+
+export { isIsolated, components };
diff --git a/src/helpers/graph/graph-layout.ts b/src/helpers/graph/graph-layout.ts
new file mode 100644
index 00000000..f1f30212
--- /dev/null
+++ b/src/helpers/graph/graph-layout.ts
@@ -0,0 +1,121 @@
+import ELK from "elkjs/lib/elk.bundled.js";
+import type { ElkNode } from "elkjs/lib/elk-api";
+import type { XYPosition } from "@vue-flow/core";
+import type { LayoutResult, GraphNode, GraphEdge } from "../../types/Graph";
+import { components, isIsolated } from "./graph-layout-utils";
+import { LayoutConfig } from "./graph-layout-config";
+
+const elk = new ELK();
+const savedPositions: Record = {};
+
+function buildElkGraph(rawNodes: GraphNode[], rawEdges: GraphEdge[]) {
+ return {
+ id: "root",
+ layoutOptions: {
+ "elk.algorithm": LayoutConfig.elkOptions.algorithm,
+ "elk.direction": LayoutConfig.elkOptions.direction,
+ "elk.spacing.nodeNode": LayoutConfig.elkOptions.nodeSpacing,
+ "elk.spacing.nodeNodeBetweenLayers": LayoutConfig.elkOptions.spacingNodeNodeBetweenLayers,
+ "elk.layered.spacing.nodeNodeBetweenLayers": LayoutConfig.elkOptions.spacingNodeNodeBetweenLayers,
+ "elk.layered.layering.strategy": LayoutConfig.elkOptions.layeringStrategy,
+ "elk.layered.crossingMinimization.strategy": LayoutConfig.elkOptions.crossingMinStrategy,
+ "elk.layered.nodePlacement.strategy": LayoutConfig.elkOptions.nodePlacementStrategy,
+ "elk.randomize": LayoutConfig.elkOptions.randomize,
+ },
+ children: rawNodes.map((n) => {
+ const base = {
+ id: n.id,
+ width: LayoutConfig.nodeSize.width,
+ height: LayoutConfig.nodeSize.height,
+ };
+ if (savedPositions[n.id]) {
+ const { x, y } = savedPositions[n.id];
+ return { ...base, x, y, layoutOptions: { "org.eclipse.elk.fixed": "true" } };
+ }
+ return base;
+ }),
+ edges: rawEdges.map((e) => ({
+ id: e.id!,
+ sources: [e.source],
+ targets: [e.target],
+ })),
+ } as const;
+}
+
+function updatePositions(layout: ElkNode, rawNodes: GraphNode[]): GraphNode[] {
+ return layout.children!.map((elkNode: ElkNode) => {
+ const original = rawNodes.find((n) => n.id === elkNode.id)!;
+ const pos = { x: elkNode.x!, y: elkNode.y! };
+ savedPositions[original.id] = pos;
+ return { ...original, position: pos };
+ });
+}
+
+function reorderComponents(nodes: GraphNode[], edges: GraphEdge[]): void {
+ const comps = components(nodes, edges)
+ .filter((comp) => comp.length > 1)
+ .sort((a, b) => b.length - a.length);
+
+ const compGap = LayoutConfig.nodeGap.height;
+ let baselineY = 0;
+ for (const comp of comps) {
+ const compNodes = nodes.filter((n) => comp.includes(n.id));
+ const minX = Math.min(...compNodes.map((n) => n.position.x));
+ const minY = Math.min(...compNodes.map((n) => n.position.y));
+ const maxY = Math.max(...compNodes.map((n) => n.position.y));
+ const shiftY = baselineY - minY;
+ const shiftX = -minX;
+
+ compNodes.forEach((n) => {
+ n.position.x += shiftX;
+ n.position.y += shiftY;
+ savedPositions[n.id] = { ...n.position };
+ });
+ baselineY = maxY + shiftY + compGap;
+ }
+}
+
+function addRandomPerturbation(nodes: GraphNode[]): void {
+ nodes.forEach((n) => {
+ n.position.x += Math.random();
+ n.position.y += Math.random();
+ });
+}
+
+function distributeIsolatedNodes(nodes: GraphNode[], edges: GraphEdge[]): void {
+ const isolatedIds = nodes.map((n) => n.id).filter((id) => isIsolated(id, edges));
+ if (isolatedIds.length === 0) return;
+
+ const nonIsolatedYs = nodes.filter((n) => !isolatedIds.includes(n.id)).map((n) => n.position.y);
+ const firstRowY =
+ (nonIsolatedYs.length ? Math.max(...nonIsolatedYs) : 0) + LayoutConfig.nodeGap.width;
+ const colGap = LayoutConfig.nodeGap.width;
+ const rowGap = LayoutConfig.nodeGap.height;
+
+ const isolatedNodes = nodes
+ .filter((n) => isolatedIds.includes(n.id))
+ .sort((a, b) => a.id.localeCompare(b.id));
+
+ const perRow = Math.ceil(isolatedNodes.length / 2);
+ isolatedNodes.forEach((node, idx) => {
+ const row = Math.floor(idx / perRow);
+ const col = idx % perRow;
+ const newPos: XYPosition = { x: col * colGap, y: firstRowY + row * rowGap };
+ node.position = newPos;
+ savedPositions[node.id] = newPos;
+ });
+}
+
+export async function sortLayout(
+ rawNodes: GraphNode[],
+ rawEdges: GraphEdge[]
+): Promise {
+ const elkGraph = buildElkGraph(rawNodes, rawEdges);
+ const layout = await elk.layout(elkGraph);
+ const laidOutNodes = updatePositions(layout, rawNodes);
+ reorderComponents(laidOutNodes, rawEdges);
+ addRandomPerturbation(laidOutNodes); //workaround, because straight lines do not render
+ distributeIsolatedNodes(laidOutNodes, rawEdges);
+
+ return { nodes: laidOutNodes, edges: rawEdges };
+}
diff --git a/src/helpers/graph/graph-nodes.ts b/src/helpers/graph/graph-nodes.ts
new file mode 100644
index 00000000..869f975e
--- /dev/null
+++ b/src/helpers/graph/graph-nodes.ts
@@ -0,0 +1,12 @@
+import type { XYPosition } from "@vue-flow/core";
+import type { Module } from "../types";
+import type { GraphNode } from "../../types/Graph";
+
+export function generateModuleNodes(modules: Module[]): GraphNode[] {
+ return modules.map((module, idx) => ({
+ id: module.id,
+ type: "module",
+ position: { x: idx, y: 0 } as XYPosition,
+ data: { moduleData: module },
+ }));
+}
diff --git a/src/helpers/store.ts b/src/helpers/store.ts
index e4649a0e..d22c2bba 100644
--- a/src/helpers/store.ts
+++ b/src/helpers/store.ts
@@ -3,7 +3,7 @@ import { AccreditedModule, Category, Focus, Module, Semester } from './types';
import { SemesterInfo } from './semester-info';
import { getColorClassForCategoryId } from '../helpers/color-helper';
-const BASE_URL = 'https://raw.githubusercontent.com/lost-university/data/5.0/data';
+const BASE_URL = 'https://raw.githubusercontent.com/Janooski/data/main/data';
const ROUTE_MODULES = '/modules.json';
const ROUTE_CATEGORIES = '/categories.json';
const ROUTE_FOCUSES = '/focuses.json';
@@ -104,7 +104,6 @@ export const store = createStore({
setAccreditedModules(state, accreditedModules: AccreditedModule[]) {
state.accreditedModules = accreditedModules;
},
-
addSemester(state) {
const newSemester = new Semester(state.semesters.length + 1, []).setName(state.startSemester);
state.semesters.push(newSemester);
@@ -117,6 +116,11 @@ export const store = createStore({
const index = semester.moduleIds.indexOf(data.moduleId);
semester.moduleIds.splice(index, 1);
},
+ removeModuleFromAllSemesters(state, moduleId: string) {
+ state.semesters.forEach(semester => {
+ semester.moduleIds = semester.moduleIds.filter(id => id !== moduleId);
+ });
+ },
addModuleToSemester(state, data: {semesterNumber: number, moduleId: string}) {
state.semesters.find(s => s.number === data.semesterNumber).moduleIds.push(data.moduleId);
},
@@ -161,7 +165,8 @@ export const store = createStore({
m.dependentModuleIds,
m.successorModuleId,
m.predecessorModuleId,
- m.isDeactivated
+ m.isDeactivated,
+ m.isMandatory
)
);
context.commit('setModules', modules);
diff --git a/src/helpers/types.ts b/src/helpers/types.ts
index 7c192805..2e56b6d0 100644
--- a/src/helpers/types.ts
+++ b/src/helpers/types.ts
@@ -47,6 +47,7 @@ export class Module {
recommendedModuleIds: string[];
dependentModuleIds: string[];
validationInfo: ModuleValidationInfo | null;
+ isMandatory: boolean = false;
// null means there cannot be a next semester for this module (reached max semesters)
nextPossibleSemester: SemesterInfo | null;
@@ -62,7 +63,8 @@ export class Module {
dependentModuleIds: string[],
successorModuleId: string,
predecessorModuleId: string,
- isDeactivated: boolean
+ isDeactivated: boolean,
+ isMandatory: boolean
) {
this.id = id;
this.name = name;
@@ -77,6 +79,7 @@ export class Module {
this.isDeactivated = isDeactivated;
this.validationInfo = null;
this.nextPossibleSemester = null;
+ this.isMandatory = isMandatory;
}
calculateNextPossibleSemester(startSemester: SemesterInfo) {
diff --git a/src/main.ts b/src/main.ts
index 736e788b..9d92a6b5 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -15,7 +15,8 @@ import {
faCheck,
faInfoCircle,
faCircleExclamation,
- faCircleQuestion
+ faCircleQuestion,
+ faPlusCircle
} from '@fortawesome/free-solid-svg-icons';
import App from './App.vue';
import router from './router';
@@ -32,6 +33,7 @@ library.add(faCheck as IconDefinition);
library.add(faInfoCircle as IconDefinition);
library.add(faCircleExclamation as IconDefinition);
library.add(faCircleQuestion as IconDefinition);
+library.add(faPlusCircle as IconDefinition);
const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY
diff --git a/src/types/Graph.ts b/src/types/Graph.ts
new file mode 100644
index 00000000..9f15e339
--- /dev/null
+++ b/src/types/Graph.ts
@@ -0,0 +1,15 @@
+import type { Module } from "../helpers/types";
+import type { Node, Edge } from "@vue-flow/core";
+
+export type GraphNode = Node<{ moduleData: Module }>;
+
+export interface LayoutResult {
+ nodes: GraphNode[];
+ edges: GraphEdge[];
+}
+
+export type GraphEdge = Edge & {
+ gradientId: string;
+ sourceColor: string;
+ targetColor: string;
+};
diff --git a/src/views/Home.vue b/src/views/Home.vue
index 5872713e..ef789cfa 100644
--- a/src/views/Home.vue
+++ b/src/views/Home.vue
@@ -16,31 +16,6 @@
/>
-
-
-
-
-
- Validierung:
-
-
-
-
-
-
-
-
-
-
import { defineComponent } from 'vue';
-import { Switch as HeadlessSwitch, SwitchGroup, SwitchLabel } from '@headlessui/vue'
-
import SemesterComponent from '../components/Semester.vue';
import FocusComponent from '../components/Focus.vue';
import ToastNotification from '../components/ToastNotification.vue';
@@ -167,20 +140,15 @@ import { StorageHelper } from '../helpers/storage-helper';
import { store } from '../helpers/store';
import { mapGetters } from 'vuex';
import AccreditedModules from '../components/AccreditedModules.vue';
-import GlobalValidationInfo from "../components/GlobalValidationInfo.vue";
export default defineComponent({
name: 'Home',
components: {
- GlobalValidationInfo,
SemesterComponent,
FocusComponent,
ToastNotification,
Categories,
AccreditedModules,
- HeadlessSwitch,
- SwitchGroup,
- SwitchLabel,
},
data() {
return {
diff --git a/style.css b/style.css
index 30aa87e2..03d6a803 100644
--- a/style.css
+++ b/style.css
@@ -1,6 +1,9 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
+@import '@vue-flow/core/dist/theme-default.css';
+@import '@vue-flow/core/dist/style.css';
+
@media (hover: none) {
[title] {
position: relative;
diff --git a/vuex.d.ts b/vuex.d.ts
new file mode 100644
index 00000000..175603a8
--- /dev/null
+++ b/vuex.d.ts
@@ -0,0 +1,8 @@
+// This is a workaround, since TS does not recognise Vuex type definitions:
+// https://stackoverflow.com/questions/76196277/could-not-find-a-declaration-file-for-module-vuex-with-create-vue
+declare module "vuex" {
+ export * from "vuex/types/index.d.ts";
+ export * from "vuex/types/helpers.d.ts";
+ export * from "vuex/types/logger.d.ts";
+ export * from "vuex/types/vue.d.ts";
+}
\ No newline at end of file