diff --git a/src/lib/components/Graph.svelte b/src/lib/components/Graph.svelte
new file mode 100644
index 00000000..d9aa4d23
--- /dev/null
+++ b/src/lib/components/Graph.svelte
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+ {#if graphSVG.interactive}
+
+
+
+
+ {/if}
+
+
+
+
+
diff --git a/src/lib/components/GraphPreview.svelte b/src/lib/components/GraphPreview.svelte
new file mode 100644
index 00000000..55dbaeaf
--- /dev/null
+++ b/src/lib/components/GraphPreview.svelte
@@ -0,0 +1,183 @@
+
+
+
+
+
+
+
+{#if visible}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{/if}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/components/GraphSVG.svelte b/src/lib/components/GraphSVG.svelte
deleted file mode 100644
index b4daebd2..00000000
--- a/src/lib/components/GraphSVG.svelte
+++ /dev/null
@@ -1,153 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {#if interactive}
-
-
-
-
- {/if}
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/lib/components/Infobox.svelte b/src/lib/components/Infobox.svelte
deleted file mode 100644
index 50a9a7a6..00000000
--- a/src/lib/components/Infobox.svelte
+++ /dev/null
@@ -1,55 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/lib/components/LinkButton.svelte b/src/lib/components/LinkButton.svelte
index b3571e15..2f9f1b1f 100644
--- a/src/lib/components/LinkButton.svelte
+++ b/src/lib/components/LinkButton.svelte
@@ -61,6 +61,7 @@
padding: $input-thin-padding
color: $purple
+ white-space: nowrap
border: 1px solid transparent
cursor: pointer
diff --git a/src/lib/components/ListRow.svelte b/src/lib/components/ListRow.svelte
deleted file mode 100644
index 364399df..00000000
--- a/src/lib/components/ListRow.svelte
+++ /dev/null
@@ -1,35 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/lib/scripts/controllers/CourseController.ts b/src/lib/scripts/controllers/CourseController.ts
index 11870127..beefd444 100644
--- a/src/lib/scripts/controllers/CourseController.ts
+++ b/src/lib/scripts/controllers/CourseController.ts
@@ -526,8 +526,8 @@ class CourseController {
const course = CourseController.revive(cache, data)
if (program !== undefined) {
- course.assignToProgram(program)
- }
+ program.assignCourse(course, false)
+ }
return course
}
diff --git a/src/lib/scripts/controllers/DomainController.ts b/src/lib/scripts/controllers/DomainController.ts
index 6ca4d162..ff73ad4a 100644
--- a/src/lib/scripts/controllers/DomainController.ts
+++ b/src/lib/scripts/controllers/DomainController.ts
@@ -64,7 +64,6 @@ class DomainController extends NodeController {
set order(value: number) {
this._order = value
- this._unchanged = false
this._unsaved = true
}
@@ -432,19 +431,20 @@ class DomainController extends NodeController {
// Unassign graph, parents, children, and subjects
if (this._graph_id !== undefined)
this.graph.unassignDomain(this)
- if (this._parent_ids !== undefined)
- for (const parent of this.parents)
- parent.unassignChild(this, false)
- if (this._child_ids !== undefined)
- for (const child of this.children)
- child.unassignParent(this, false)
if (this._subject_ids !== undefined)
for (const subject of this.subjects)
subject.unassignDomain(this, false)
+ for (const relation of this.graph.domain_relations) {
+ if (relation.parent === this)
+ relation.parent = null
+ else if (relation.child === this)
+ relation.child = null
+ }
+
// Fix order of remaining domains
if (reorder_graph) {
- await this.graph.reorder()
+ await this.graph.reorderDomains()
}
// Call the API to delete the domain
diff --git a/src/lib/scripts/controllers/GraphController.ts b/src/lib/scripts/controllers/GraphController.ts
index 0256baf3..1d0dd34e 100644
--- a/src/lib/scripts/controllers/GraphController.ts
+++ b/src/lib/scripts/controllers/GraphController.ts
@@ -189,6 +189,8 @@ class GraphController {
// Fetch lectures from the cache
this._lectures = this._lecture_ids.map(id => this.cache.findOrThrow(LectureController, id))
+ this._lectures.sort((a, b) => a.order - b.order)
+
return Array.from(this._lectures)
}
@@ -577,7 +579,7 @@ class GraphController {
if (this._subject_ids !== undefined)
promises.push(...this.subjects.map(async subject => await subject.delete()))
if (this._lecture_ids !== undefined)
- promises.push(...this.lectures.map(async lecture => await lecture.delete()))
+ promises.push(...this.lectures.map(async lecture => await lecture.delete(false)))
await Promise.all(promises)
// Call the API to delete the graph
@@ -592,7 +594,7 @@ class GraphController {
this.cache.remove(this)
}
- async reorder(domains?: DomainController[]) {
+ async reorderDomains(domains?: DomainController[]) {
// Update the graph
if (domains !== undefined) {
@@ -608,7 +610,7 @@ class GraphController {
}
// Call the API to reorder the graph
- const response = await fetch(`/api/graph/${this.id}/reorder`, {
+ const response = await fetch(`/api/graph/${this.id}/reorder/domains`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain_ids: this.domain_ids })
@@ -616,7 +618,35 @@ class GraphController {
// Throw an error if the API request fails
if (!response.ok) {
- throw new Error(`APIError (/api/graph/${this.id}/reorder PUT): ${response.status} ${response.statusText}`)
+ throw new Error(`APIError (/api/graph/${this.id}/reorder/domains PUT): ${response.status} ${response.statusText}`)
+ }
+ }
+
+ async reorderLectures(lectures?: LectureController[]) {
+
+ // Update the graph
+ if (lectures !== undefined) {
+ this._lecture_ids = lectures.map(lecture => lecture.id)
+ this._lectures = lectures
+ this._unchanged = false
+ this._unsaved = true
+ }
+
+ // Update lectures
+ for (const [index, lecture] of this.lectures.entries()) {
+ lecture.order = index
+ }
+
+ // Call the API to reorder the graph
+ const response = await fetch(`/api/graph/${this.id}/reorder/lectures`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ lecture_ids: this.lecture_ids })
+ })
+
+ // Throw an error if the API request fails
+ if (!response.ok) {
+ throw new Error(`APIError (/api/graph/${this.id}/reorder/lectures PUT): ${response.status} ${response.statusText}`)
}
}
@@ -720,7 +750,7 @@ class GraphController {
const copied_lecture = await lecture.copy(copied_graph)
// Assign copied subjects
- for (const subject of lecture.subjects) {
+ for (const subject of lecture.present_subjects) {
const copied_subject = subject_map.get(subject.id)
if (copied_subject === undefined) {
throw new Error(`GraphError: Subject incorrectly copied`)
diff --git a/src/lib/scripts/controllers/LectureController.ts b/src/lib/scripts/controllers/LectureController.ts
index c1173091..3c97aacd 100644
--- a/src/lib/scripts/controllers/LectureController.ts
+++ b/src/lib/scripts/controllers/LectureController.ts
@@ -10,7 +10,8 @@ import { Validation, Severity } from '$scripts/validation'
import {
ControllerCache,
GraphController,
- SubjectController
+ SubjectController,
+ SubjectRelationController
} from '$scripts/controllers'
import { validSerializedLecture } from '$scripts/types'
@@ -32,7 +33,7 @@ class LectureController {
private _unsaved: boolean = false
private _graph?: GraphController
- private _subjects?: SubjectController[]
+ private _present_subjects?: SubjectController[]
private constructor(
public cache: ControllerCache,
@@ -41,7 +42,7 @@ class LectureController {
private _name: string,
private _order: number,
private _graph_id?: number,
- private _subject_ids?: number[]
+ private _present_subject_ids?: number[]
) {
this.cache.add(this)
}
@@ -75,7 +76,6 @@ class LectureController {
set order(value: number) {
this._order = value
- this._unchanged = false
this._unsaved = true
}
@@ -97,34 +97,34 @@ class LectureController {
return this._graph
}
- // Subject properties
- get subject_ids(): number[] {
- if (this._subject_ids === undefined)
+ // Present subject properties
+ get present_subject_ids(): number[] {
+ if (this._present_subject_ids === undefined)
throw new Error('LectureError: Subject data unknown')
- return Array.from(this._subject_ids)
+ return Array.from(this._present_subject_ids)
}
- get subjects(): SubjectController[] {
- if (this._subject_ids === undefined)
+ get present_subjects(): SubjectController[] {
+ if (this._present_subject_ids === undefined)
throw new Error('LectureError: Subject data unknown')
- if (this._subjects !== undefined)
- return Array.from(this._subjects)
+ if (this._present_subjects !== undefined)
+ return Array.from(this._present_subjects)
// Fetch subjects from cache
- this._subjects = this._subject_ids.map(id => this.cache.findOrThrow(SubjectController, id))
- return Array.from(this._subjects)
+ this._present_subjects = this._present_subject_ids.map(id => this.cache.findOrThrow(SubjectController, id))
+ return Array.from(this._present_subjects)
}
get subject_options(): DropdownOption[] {
return this.graph.subjects.map(subject => {
const validation = new Validation()
- if (this.subject_ids.includes(subject.id)) {
+ if (this.present_subject_ids.includes(subject.id)) {
validation.add({
severity: Severity.error,
short: 'Already assigned here'
})
- } else if (this.graph.lectures.find(lecture => lecture.subject_ids.includes(subject.id))) {
+ } else if (this.graph.lectures.find(lecture => lecture.present_subject_ids.includes(subject.id))) {
validation.add({
severity: Severity.warning,
short: 'Already assigned elsewhere'
@@ -139,14 +139,59 @@ class LectureController {
})
}
+ // Past subject properties
+ get past_subjects(): SubjectController[] {
+ const result: SubjectController[] = []
+ for (const subject of this.present_subjects) {
+ for (const parent of subject.parents) {
+ if (!result.includes(parent) && !this.present_subjects.includes(parent))
+ result.push(parent)
+ }
+ }
+
+ return result
+ }
+
+ // Future subject properties
+ get future_subjects(): SubjectController[] {
+ const result: SubjectController[] = []
+ for (const subject of this.present_subjects) {
+ for (const child of subject.children) {
+ if (!result.includes(child) && !this.present_subjects.includes(child))
+ result.push(child)
+ }
+ }
+
+ return result
+ }
+
+ // Subject properties
+ get subjects(): SubjectController[] {
+ return this.present_subjects
+ .concat(this.past_subjects)
+ .concat(this.future_subjects)
+ }
+
+ // Relation properties
+ get relations(): SubjectRelationController[] {
+ return this.graph.subject_relations
+ .filter(relation => relation.parent !== null && relation.child !== null)
+ .filter(relation => this.present_subjects.includes(relation.parent!) || this.present_subjects.includes(relation.child!))
+ }
+
+ // Height properties
+ get max_height(): number {
+ return Math.max(this.present_subjects.length, this.past_subjects.length, this.future_subjects.length)
+ }
+
// --------------------> Assignments
assignSubject(subject: SubjectController, mirror: boolean = true) {
- if (this._subject_ids !== undefined) {
- if (this._subject_ids.includes(subject.id))
+ if (this._present_subject_ids !== undefined) {
+ if (this._present_subject_ids.includes(subject.id))
throw new Error(`LectureError: Subject with ID ${subject.id} already assigned to lecture with ID ${this.id}`)
- this._subject_ids.push(subject.id)
- this._subjects?.push(subject)
+ this._present_subject_ids.push(subject.id)
+ this._present_subjects?.push(subject)
this._unchanged = false
this._unsaved = true
}
@@ -157,11 +202,11 @@ class LectureController {
}
unassignSubject(subject: SubjectController, mirror: boolean = true) {
- if (this._subject_ids !== undefined) {
- if (!this._subject_ids.includes(subject.id))
+ if (this._present_subject_ids !== undefined) {
+ if (!this._present_subject_ids.includes(subject.id))
throw new Error(`LectureError: Subject with ID ${subject.id} not assigned to lecture with ID ${this.id}`)
- this._subject_ids = this._subject_ids.filter(id => id !== subject.id)
- this._subjects = this._subjects?.filter(s => s.id !== subject.id)
+ this._present_subject_ids = this._present_subject_ids.filter(id => id !== subject.id)
+ this._present_subjects = this._present_subjects?.filter(s => s.id !== subject.id)
this._unchanged = false
this._unsaved = true
}
@@ -210,7 +255,7 @@ class LectureController {
const validation = new Validation()
if (!strict && this._unchanged) return validation
- if (this.subject_ids.length === 0) {
+ if (this.present_subject_ids.length === 0) {
validation.add({
severity: Severity.warning,
short: 'Lecture has no subjects',
@@ -219,8 +264,8 @@ class LectureController {
})
} else if (this.graph.lectures
.find(lecture =>
- lecture.id !== this.id && lecture.subject_ids.find(
- id => this.subject_ids.includes(id)
+ lecture.id !== this.id && lecture.present_subject_ids.find(
+ id => this.present_subject_ids.includes(id)
)
)
) {
@@ -283,8 +328,8 @@ class LectureController {
// Update lecture where necessary
if (lecture._graph_id === undefined)
lecture._graph_id = data.graph_id
- if (lecture._subject_ids === undefined)
- lecture._subject_ids = data.subject_ids
+ if (lecture._present_subject_ids === undefined)
+ lecture._present_subject_ids = data.subject_ids
return lecture
}
@@ -306,7 +351,7 @@ class LectureController {
&& this.trimmed_name === data.name
&& this.order === data.order
&& (this._graph_id === undefined || data.graph_id === undefined || this._graph_id === data.graph_id)
- && (this._subject_ids === undefined || data.subject_ids === undefined || compareArrays(this._subject_ids, data.subject_ids))
+ && (this._present_subject_ids === undefined || data.subject_ids === undefined || compareArrays(this._present_subject_ids, data.subject_ids))
}
reduce(): SerializedLecture {
@@ -316,7 +361,7 @@ class LectureController {
name: this.trimmed_name,
order: this.order,
graph_id: this._graph_id,
- subject_ids: this._subject_ids
+ subject_ids: this._present_subject_ids
}
}
@@ -338,14 +383,19 @@ class LectureController {
this._unsaved = false
}
- async delete() {
+ async delete(reorder_graph: boolean = true) {
// Unassign graph and subjects
if (this._graph_id !== undefined)
this.graph.unassignLecture(this)
- if (this._subject_ids !== undefined)
- for (const subject of this.subjects)
+ if (this._present_subject_ids !== undefined)
+ for (const subject of this.present_subjects)
subject.unassignFromLecture(this, false)
+
+ // Fix order of remaining lectures
+ if (reorder_graph) {
+ await this.graph.reorderLectures()
+ }
// Call the API to delete the lecture
const response = await fetch(`/api/lecture/${this.id}`, { method: 'DELETE' })
@@ -372,7 +422,7 @@ class LectureController {
matchesQuery(query: string): boolean {
const lower_query = query.toLowerCase()
const lower_name = this.trimmed_name.toLowerCase()
- const lower_subjects = this.subjects.map(subject => subject.trimmed_name.toLowerCase())
+ const lower_subjects = this.present_subjects.map(subject => subject.trimmed_name.toLowerCase())
return lower_name.includes(lower_query) || lower_subjects.some(subject => subject.includes(lower_query))
}
diff --git a/src/lib/scripts/controllers/LinkController.ts b/src/lib/scripts/controllers/LinkController.ts
index b42203ab..7ef53f12 100644
--- a/src/lib/scripts/controllers/LinkController.ts
+++ b/src/lib/scripts/controllers/LinkController.ts
@@ -111,7 +111,7 @@ class LinkController {
get url(): string {
if (this.validate().severity === Severity.error)
return ''
- return `/app/course/${this.course.code}/${this.name}`
+ return `${settings.ROOT_URL}/app/course/${this.course.code}/${this.name}`
}
// --------------------> Assignments
diff --git a/src/lib/scripts/controllers/NodeController.ts b/src/lib/scripts/controllers/NodeController.ts
index 307cb62b..e8277eea 100644
--- a/src/lib/scripts/controllers/NodeController.ts
+++ b/src/lib/scripts/controllers/NodeController.ts
@@ -24,6 +24,8 @@ export { NodeController }
abstract class NodeController {
public uuid: string = uuid.v4()
+ public fx?: number
+ public fy?: number
protected _unsaved: boolean = false
protected _graph?: GraphController
@@ -40,7 +42,10 @@ abstract class NodeController {
protected _graph_id?: number,
protected _parent_ids?: number[],
protected _child_ids?: number[]
- ) { }
+ ) {
+ this.fx = this._x
+ this.fy = this._y
+ }
// --------------------> Getters & Setters
diff --git a/src/lib/scripts/controllers/RelationControllers.ts b/src/lib/scripts/controllers/RelationControllers.ts
index 62bcaaef..46c236c4 100644
--- a/src/lib/scripts/controllers/RelationControllers.ts
+++ b/src/lib/scripts/controllers/RelationControllers.ts
@@ -22,7 +22,7 @@ export { RelationController, DomainRelationController, SubjectRelationController
abstract class RelationController {
uuid: string = uuid.v4()
- protected _unchanged: boolean = false
+ protected _unchanged: boolean = true
protected _parent: T | null = null
protected _child: T | null = null
diff --git a/src/lib/scripts/helpers/GraphHelper.ts b/src/lib/scripts/helpers/GraphHelper.ts
index 8b238575..820f8932 100644
--- a/src/lib/scripts/helpers/GraphHelper.ts
+++ b/src/lib/scripts/helpers/GraphHelper.ts
@@ -149,7 +149,7 @@ export async function update(data: SerializedGraph) {
}
}
-export async function reorder(domain_ids: number[]) {
+export async function reorderDomains(domain_ids: number[]) {
return await Promise.all(
domain_ids.map(async (domain_id, order) => {
try {
@@ -168,6 +168,25 @@ export async function reorder(domain_ids: number[]) {
)
}
+export async function reorderLectures(lecture_ids: number[]) {
+ return await Promise.all(
+ lecture_ids.map(async (lecture_id, order) => {
+ try {
+ return prisma.lecture.update({
+ where: {
+ id: lecture_id
+ },
+ data: {
+ order: order
+ }
+ })
+ } catch (error) {
+ return Promise.reject(error)
+ }
+ })
+ )
+}
+
export async function remove(id: number) {
try {
await prisma.graph.delete({
diff --git a/src/lib/scripts/helpers/LinkHelper.ts b/src/lib/scripts/helpers/LinkHelper.ts
index ad921125..4602d240 100644
--- a/src/lib/scripts/helpers/LinkHelper.ts
+++ b/src/lib/scripts/helpers/LinkHelper.ts
@@ -158,4 +158,26 @@ export async function getGraph(id: number, ...relations: GraphRelation[]): Promi
if (data.graph !== null)
return await GraphHelper.reduce(data.graph, ...relations)
return null
+}
+
+export async function getGraphFromCourseAndName(course_code: string, link_name: string, ...relations: GraphRelation[]): Promise {
+ try {
+ var data = await prisma.link.findFirstOrThrow({
+ where: {
+ course: {
+ code: course_code
+ },
+ name: link_name
+ },
+ select: {
+ graph: true
+ }
+ })
+ } catch (error) {
+ return Promise.reject(error)
+ }
+
+ if (data.graph !== null)
+ return await GraphHelper.reduce(data.graph, ...relations)
+ return Promise.reject('No graph associated with this link')
}
\ No newline at end of file
diff --git a/src/lib/scripts/modals.ts b/src/lib/scripts/modals.ts
index 69cfc095..12f3d4a3 100644
--- a/src/lib/scripts/modals.ts
+++ b/src/lib/scripts/modals.ts
@@ -2,15 +2,42 @@
// External imports
import type Modal from '$components/Modal.svelte'
+// Internal imports
+import { Validation, Severity } from './validation'
+
// --------------------> Classes
+export abstract class SimpleModal {
+ disabled: boolean = false
+ modal?: Modal
+
+ show() {
+ this.modal?.show()
+ }
+
+ hide() {
+ this.modal?.hide()
+ this.disabled = false
+ }
+
+ abstract submit(): Promise
+}
export abstract class FormModal {
private defaults: { [key: string]: any } = {}
private changed: { [key: string]: boolean } = {}
+ private _disabled: boolean = false
private _modal?: Modal
+ get disabled() {
+ return this._disabled || this.validate().severity === Severity.error
+ }
+
+ set disabled(disabled: boolean) {
+ this._disabled = disabled
+ }
+
get modal() {
return this._modal
}
@@ -30,6 +57,7 @@ export abstract class FormModal {
}
protected reset() {
+ this.disabled = false
for (const property in this) {
if (this.isField(property)) {
this[property] = this.defaults[property]
@@ -54,7 +82,10 @@ export abstract class FormModal {
}
private isField(property: string): boolean {
- return property !== '_modal' && property !== 'modal' && property !== 'defaults' && property !== 'changed'
+ return property !== '_modal' && property !== 'modal'
+ && property !== '_disabled' && property !== 'disabled'
+ && property !== 'defaults'
+ && property !== 'changed'
}
show() {
@@ -64,4 +95,6 @@ export abstract class FormModal {
hide() {
this.modal?.hide()
}
+
+ abstract validate(): Validation
}
\ No newline at end of file
diff --git a/src/lib/scripts/settings.ts b/src/lib/scripts/settings.ts
index faee5e71..5275a4dd 100644
--- a/src/lib/scripts/settings.ts
+++ b/src/lib/scripts/settings.ts
@@ -6,6 +6,8 @@ type Milliseconds = number
// -------------------> General settings
+export const ROOT_URL = 'localhost:5173'
+
export const MAX_PROGRAM_NAME_LENGTH: Scalar = 50
export const COURSE_CODE_REGEX: RegExp = /^[A-Za-z0-9]*$/
@@ -25,15 +27,16 @@ export const FEEDBACK_FADE_DURATION: Milliseconds = 500
// -------------------> Editor settings
// Grid settings
-export const GRID_COLOR = '#a4a4a4'
+export const GRID_PADDING: GridUnits = 5
export const GRID_UNIT: Scalar = 10
+export const GRID_COLOR = '#a4a4a4'
// Zoom settings
export const MIN_ZOOM: Scalar = 0
export const MAX_ZOOM: Scalar = 1.5
export const ZOOM_STEP: Scalar = 1.5
-// Field settings
+// Node settings
export const NODE_WIDTH: GridUnits = 16
export const NODE_HEIGHT: GridUnits = 8
export const NODE_MARGIN: GridUnits = 1.5
@@ -61,8 +64,12 @@ export const OVERLAY_SMALL_FONT: Pixels = 20
export const OVERLAY_FADE_OUT: Milliseconds = 500
export const OVERLAY_LINGER: Milliseconds = 1500
+// Simulation settings
+export const CENTER_FORCE: Scalar = 0.05
+export const CHARGE_FORCE: Scalar = -15
+
// Animation settings
-export const FADE_DURATION: Milliseconds = 0
+export const FADE_DURATION: Milliseconds = 200
export const ANIMATION_DURATION: Milliseconds = 1000
export const SHAKE = {
delay: 150,
@@ -81,7 +88,7 @@ export const SHAKE = {
}
}
-// Node style constants
+// Node style settings
const top = STROKE_WIDTH / 2
const right = NODE_WIDTH * GRID_UNIT - STROKE_WIDTH / 2
const bottom = NODE_HEIGHT * GRID_UNIT - STROKE_WIDTH / 2
diff --git a/src/lib/scripts/svg/GraphSVG.ts b/src/lib/scripts/svg/GraphSVG.ts
new file mode 100644
index 00000000..590b2301
--- /dev/null
+++ b/src/lib/scripts/svg/GraphSVG.ts
@@ -0,0 +1,1020 @@
+
+// External imports
+import * as d3 from 'd3'
+
+// Internal imports
+import * as settings from '$scripts/settings'
+import { Severity } from '$scripts/validation'
+
+import {
+ NodeSVG,
+ RelationSVG,
+ OverlaySVG
+} from '$scripts/svg'
+
+import {
+ GraphController,
+ DomainController,
+ SubjectController,
+ RelationController,
+ LectureController,
+ NodeController
+} from '$scripts/controllers'
+
+import type {
+ EditorView
+} from '$scripts/types'
+
+// Exports
+export { GraphSVG, SVGState }
+
+
+// --------------------> Classes
+
+
+const ANIMATE = () => {}
+
+enum SVGState {
+ detached, // When the graph is detached from the DOM
+ static, // When the graph cannot be interacted with
+ dynamic, // When the graph can be interacted with
+ animating, // When the graph is in the process of transitioning
+ lecture, // When the graph is displaying a lecture
+ broken // When the current view is broken
+}
+
+class GraphSVG {
+ private _graph: GraphController
+ private _interactive: boolean
+ private _view: EditorView = 'domains'
+ private _state: SVGState = SVGState.detached
+ private _lecture: LectureController | null = null
+
+ private svg?: SVGSVGElement
+ private zoom?: d3.ZoomBehavior
+ private simulation?: d3.Simulation
+
+ private keys: { [key: string]: boolean } = {}
+
+ public shift_zoom: boolean = true
+
+ constructor(graph: GraphController, interactive: boolean = true) {
+ this._interactive = interactive
+ this._graph = graph
+ }
+
+ // -----------------> Getters & Setters
+
+ get graph() {
+ return this._graph
+ }
+
+ get interactive() {
+ return this._interactive
+ }
+
+ get view() {
+ return this._view
+ }
+
+ set view(view: EditorView) {
+ if (this.view === view || this.state === SVGState.animating)
+ return
+
+ // If hidden, save view for later
+ if (this.state === SVGState.detached) {
+ this._view = view
+ return
+ }
+
+ // Validate view
+ if (!this.validateView(view)) {
+ this.state = SVGState.broken
+ this._view = view
+ return
+ }
+
+ // Transition to new view
+ switch (this.view) {
+ case 'domains':
+ switch (view) {
+ case 'subjects':
+ this.domainToSubject()
+ break
+ case 'lectures':
+ this.domainToLecture()
+ break
+ } break
+ case 'subjects':
+ switch (view) {
+ case 'domains':
+ this.subjectToDomain()
+ break
+ case 'lectures':
+ this.subjectToLecture()
+ break
+ } break
+ case 'lectures':
+ switch (view) {
+ case 'domains':
+ this.lectureToDomain()
+ break
+ case 'subjects':
+ this.lectureToSubject()
+ break
+ } break
+ }
+
+ this._view = view
+ }
+
+ get state() {
+ return this._state
+ }
+
+ private set state(state: SVGState) {
+ if (this.state === state)
+ return
+ if (this.svg === undefined || this.simulation === undefined)
+ throw new Error('Failed to set state: GraphSVG not attached to DOM')
+
+ // If moving out of dynamic state:
+ // - Fix all nodes
+ // - Save all nodes
+ // - Stop simulation
+ if (this.state === SVGState.dynamic) {
+ d3.select(this.svg)
+ .select('#content')
+ .selectAll>('.node')
+ .call(NodeSVG.setFixed, true)
+ .each(async node => await node.save())
+
+ this.simulation.stop()
+ }
+
+ // If moving out of broken state:
+ // - Reset overlay
+ else if (this.state === SVGState.broken) {
+ d3.select('#overlay')
+ .call(OverlaySVG.reset)
+ }
+
+ // If moving out of detached state:
+ // - Set initial view
+ else if (this.state === SVGState.detached) {
+ switch (this.view) {
+ case 'domains':
+ this.DomainsView()
+ break
+ case 'subjects':
+ this.SubjectsView()
+ break
+ case 'lectures':
+ this.LecturesView()
+ break
+ }
+ }
+
+ this._state = state // NEW STATE
+
+ // If moving into broken state:
+ // - Set overlay to broken
+ if (state === SVGState.broken) {
+ d3.select('#overlay')
+ .call(OverlaySVG.broken)
+ }
+
+ // If moving into detached state:
+ // - Clear background
+ // - Clear content
+ else if (state === SVGState.detached) {
+ this.clearBackground()
+ this.clearContent()
+ }
+ }
+
+ get lecture() {
+ return this._lecture
+ }
+
+ set lecture(lecture: LectureController | null) {
+ if (this.lecture === lecture || this.state === SVGState.animating)
+ return
+
+ this._lecture = lecture
+
+ if (this.state === SVGState.detached)
+ return
+ if (this.svg === undefined)
+ throw new Error('Failed to set lecture: GraphSVG not attached to DOM')
+
+ // Validate lecture
+ if (!this.validateView(this.view)) {
+ this.state = SVGState.broken
+ return
+ }
+
+ // Update content
+ if (this.view === 'lectures') {
+ this.setBackground('lectures')
+ this.moveCamera(0, 0, 1)
+
+ if (this.lecture === null) {
+ this.clearContent()
+ } else {
+ this.setContent(this.lecture.subjects, this.lecture.relations)
+ this.moveContent(this.lecture.subjects, this.lectureTransform(0, 0))
+ }
+ }
+
+ this.state = SVGState.lecture
+
+ // Update highlights
+ d3.select(this.svg)
+ .select('#content')
+ .selectAll>('.node')
+ .call(NodeSVG.updateHighlight, this.lecture)
+ }
+
+ get autolayout_enabled() {
+ return d3.select('#content')
+ .selectAll>('.node:not(.fixed)')
+ .size() > 0
+ }
+
+ // -----------------> Public Methods
+
+ attach(element: SVGSVGElement) {
+
+ // SVG setup
+ this.svg = element
+ const svg = d3.select(this.svg)
+ const definitions = svg.append('defs')
+ svg.append('g').attr('id', 'background')
+ svg.append('g').attr('id', 'content')
+ svg.append('g').attr('id', 'overlay')
+
+ // Arrowhead pattern
+ definitions.append('marker')
+ .attr('id', 'arrowhead')
+ .attr('viewBox', '0 0 10 10')
+ .attr('refX', 5)
+ .attr('refY', 5)
+ .attr('markerWidth', 6)
+ .attr('markerHeight', 6)
+ .attr('orient', 'auto-start-reverse')
+ .append('path')
+ .attr('fill', 'context-fill')
+ .attr('d', `M 0 0 L 10 5 L 0 10 Z`)
+
+ // Grid pattern
+ const grid = definitions.append('pattern')
+ .attr('id', 'grid')
+ .attr('width', settings.GRID_UNIT)
+ .attr('height', settings.GRID_UNIT)
+ .attr('patternUnits', 'userSpaceOnUse')
+
+ grid.append('line')
+ .attr('x1', 0)
+ .attr('y1', 0)
+ .attr('x2', settings.GRID_UNIT * settings.MAX_ZOOM)
+ .attr('y2', 0)
+ .attr('stroke', settings.GRID_COLOR)
+
+ grid.append('line')
+ .attr('x1', 0)
+ .attr('y1', 0)
+ .attr('x2', 0)
+ .attr('y2', settings.GRID_UNIT * settings.MAX_ZOOM)
+ .attr('stroke', settings.GRID_COLOR)
+
+ // Highlight filter
+ definitions.append('filter')
+ .attr('id', 'highlight')
+ .append('feDropShadow')
+ .attr('dx', 0)
+ .attr('dy', 0)
+ .attr('stdDeviation', settings.NODE_HIGHLIGHT_DEVIATION)
+ .attr('flood-opacity', settings.NODE_HIGHLIGHT_OPACITY)
+ .attr('flood-color', settings.NODE_HIGHLIGHT_COLOR)
+
+ // Zoom warning
+ svg.on('wheel', () => {
+ if (!this.shift_zoom || this.keys.Shift) return
+ d3.select('#overlay')
+ .call(OverlaySVG.shiftWarning, this)
+ })
+
+ // Keylogging
+ d3.select('body')
+ .on('keydown', event => {
+ this.keys[event.key] = true
+ })
+ .on('keyup', event => {
+ this.keys[event.key] = false
+ })
+
+ // Zoom & pan
+ this.zoom = d3.zoom()
+ .scaleExtent([settings.MIN_ZOOM, settings.MAX_ZOOM])
+ .filter((event) => !this.shift_zoom || this.keys.Shift || event.type === 'mousedown')
+ .on('zoom', event => {
+
+ // Update content
+ svg.select('#content')
+ .attr('transform', event.transform)
+
+ // Update grid
+ svg.select('#grid')
+ .attr('x', event.transform.x)
+ .attr('y', event.transform.y)
+ .attr('width', settings.GRID_UNIT * event.transform.k)
+ .attr('height', settings.GRID_UNIT * event.transform.k)
+ .selectAll('line')
+ .style('opacity', Math.min(1, event.transform.k))
+ })
+
+ svg
+ .call(this.zoom)
+ .on('dblclick.zoom', null)
+
+ // Simulation
+ this.simulation = d3.forceSimulation()
+ .force('x', d3.forceX(0).strength(settings.CENTER_FORCE))
+ .force('y', d3.forceY(0).strength(settings.CENTER_FORCE))
+ .force('charge', d3.forceManyBody().strength(settings.CHARGE_FORCE))
+ .on('tick', () => {
+ d3.select('#content')
+ .selectAll>('.node')
+ .call(NodeSVG.updatePosition)
+ })
+
+ // Exit hidden state
+ this.simulation.stop() // Stop simulation to prevent node movement on first render
+ this.state = this.view === 'lectures' ? SVGState.lecture : this.interactive ? SVGState.dynamic : SVGState.static
+
+ return {
+ destroy: () => this.detach()
+ }
+ }
+
+ detach() {
+ if (this.svg === undefined)
+ throw new Error('GraphSVG not attached to DOM')
+
+ // Exit current state
+ this.state = SVGState.detached
+
+ // Clear SVG
+ d3.select(this.svg)
+ .selectAll('*')
+ .remove()
+
+ // Reset properties
+ this.svg = undefined
+ this.zoom = undefined
+ this.simulation = undefined
+ this.keys = {}
+ }
+
+ zoomIn() {
+ if (this.svg === undefined || this.zoom === undefined)
+ throw new Error('GraphSVG not attached to DOM')
+ if (this.state !== SVGState.dynamic)
+ return
+
+ d3.select(this.svg)
+ .transition()
+ .duration(settings.ANIMATION_DURATION)
+ .ease(d3.easeSinInOut)
+ .call(this.zoom.scaleBy, settings.ZOOM_STEP)
+ }
+
+ zoomOut() {
+ if (this.svg === undefined || this.zoom === undefined)
+ throw new Error('GraphSVG not attached to DOM')
+ if (this.state !== SVGState.dynamic)
+ return
+
+ d3.select(this.svg)
+ .transition()
+ .duration(settings.ANIMATION_DURATION)
+ .ease(d3.easeSinInOut)
+ .call(this.zoom.scaleBy, 1 / settings.ZOOM_STEP)
+ }
+
+ findGraph() {
+ if (this.state !== SVGState.dynamic && this.state !== SVGState.static) return
+
+ this.state = SVGState.animating
+ const bbx = this.boundingBox(this.view === 'domains' ? this.graph.domains : this.graph.subjects)
+ this.moveCamera(bbx.x, bbx.y, bbx.k, () => {
+ this.state = SVGState.dynamic
+ })
+ }
+
+ toggleAutolayout() {
+ if (this.simulation === undefined)
+ throw new Error('GraphSVG not attached to DOM')
+ if (this.state !== SVGState.dynamic)
+ return
+
+ const autolayout_enabled = this.autolayout_enabled
+ d3.select('#content')
+ .selectAll>('.node')
+ .call(NodeSVG.setFixed, autolayout_enabled)
+ .each(async node => { if (autolayout_enabled) await node.save() })
+
+ if (autolayout_enabled) {
+ this.simulation.stop()
+ } else {
+ this.microwaveSimulation()
+ }
+ }
+
+ microwaveSimulation() {
+ if (this.simulation === undefined)
+ throw new Error('GraphSVG not attached to DOM')
+ this.simulation
+ .alpha(1)
+ .restart()
+ }
+
+ // -----------------> Private Methods
+
+ private validateView(view: EditorView) {
+ switch (view) {
+ case 'domains':
+ return this.graph.domains.every(domain => domain.validate().severity !== Severity.error)
+
+ case 'subjects':
+ return this.graph.subjects.every(subject => subject.validate().severity !== Severity.error)
+
+ case 'lectures':
+ return (
+ this.lecture === null ||
+ this.lecture.validate().severity !== Severity.error &&
+ this.lecture.present_subjects.every(subject => subject.validate().severity !== Severity.error)
+ )
+ }
+ }
+
+ private boundingBox(nodes: NodeController[]) {
+ if (this.svg === undefined)
+ throw new Error('GraphSVG not attached to DOM')
+
+ let min_x = Infinity
+ let min_y = Infinity
+ let max_x = -Infinity
+ let max_y = -Infinity
+
+ // Find most outer nodes
+ for (const node of nodes) {
+ min_x = Math.min(min_x, node.x - settings.NODE_MARGIN)
+ min_y = Math.min(min_y, node.y - settings.NODE_MARGIN)
+ max_x = Math.max(max_x, node.x + settings.NODE_WIDTH + settings.NODE_MARGIN)
+ max_y = Math.max(max_y, node.y + settings.NODE_HEIGHT + settings.NODE_MARGIN)
+ }
+
+ // Apply grid padding
+ min_x -= settings.GRID_PADDING
+ min_y -= settings.GRID_PADDING
+ max_x += settings.GRID_PADDING
+ max_y += settings.GRID_PADDING
+
+ // Find center and zoom
+ return {
+ x: (max_x + min_x) / 2,
+ y: (max_y + min_y) / 2,
+ k: Math.max(
+ settings.MIN_ZOOM,
+ Math.min(
+ settings.MAX_ZOOM,
+ this.svg.clientWidth / ((max_x - min_x) * settings.GRID_UNIT),
+ this.svg.clientHeight / ((max_y - min_y) * settings.GRID_UNIT)
+ )
+ )
+ }
+ }
+
+ private moveCamera(x: number, y: number, k: number, callback?: () => void) {
+ if (this.svg === undefined || this.zoom === undefined)
+ throw new Error('GraphSVG not attached to DOM')
+
+ // Call zoom with custom transform
+ d3.select(this.svg)
+ .transition()
+ .duration(callback !== undefined ? settings.ANIMATION_DURATION : 0)
+ .ease(d3.easeSinInOut)
+ .call(
+ this.zoom.transform,
+ d3.zoomIdentity
+ .translate(
+ this.svg.clientWidth / 2 - k * x * settings.GRID_UNIT,
+ this.svg.clientHeight / 2 - k * y * settings.GRID_UNIT
+ )
+ .scale(k)
+ )
+
+ // Post-transition
+ if (callback) {
+ setTimeout(() => {
+ callback()
+ }, settings.ANIMATION_DURATION)
+ }
+ }
+
+ private panCamera(x: number, y: number, callback?: () => void) {
+ if (this.svg === undefined || this.zoom === undefined)
+ throw new Error('GraphSVG not attached to DOM')
+
+ // Call zoom with custom transform
+ d3.select(this.svg)
+ .transition()
+ .duration(callback !== undefined ? settings.ANIMATION_DURATION : 0)
+ .ease(d3.easeSinInOut)
+ .call(
+ this.zoom.translateTo,
+ x * settings.GRID_UNIT,
+ y * settings.GRID_UNIT
+ )
+
+ // Post-transition
+ if (callback) {
+ setTimeout(() => {
+ callback()
+ }, settings.ANIMATION_DURATION)
+ }
+ }
+
+ private zoomCamera(k: number, callback?: () => void) {
+ if (this.svg === undefined || this.zoom === undefined)
+ throw new Error('GraphSVG not attached to DOM')
+
+ // Call zoom with custom transform
+ d3.select(this.svg)
+ .transition()
+ .duration(callback !== undefined ? settings.ANIMATION_DURATION : 0)
+ .ease(d3.easeSinInOut)
+ .call(this.zoom.scaleTo, k)
+
+ // Post-transition
+ if (callback) {
+ setTimeout(() => {
+ callback()
+ }, settings.ANIMATION_DURATION)
+ }
+ }
+
+ private setBackground(view: EditorView) {
+ if (this.svg === undefined)
+ throw new Error('GraphSVG not attached to DOM')
+ const background = d3.select('#background')
+
+ // Remove old background
+ this.clearBackground()
+
+ // Lecture background
+ if (view === 'lectures') {
+ const size = this.lecture?.max_height || 0
+ const dx = (this.svg.clientWidth - 3 * settings.LECTURE_COLUMN_WIDTH * settings.GRID_UNIT) / 2
+ const dy = (this.svg.clientHeight - (size * settings.NODE_HEIGHT + (size + 1) * settings.LECTURE_PADDING + settings.LECTURE_HEADER_HEIGHT) * settings.GRID_UNIT) / 2
+
+ // Past subject colunm
+ background.append('rect')
+ .attr('x', dx + settings.STROKE_WIDTH / 2)
+ .attr('y', dy + settings.STROKE_WIDTH / 2 + settings.LECTURE_HEADER_HEIGHT * settings.GRID_UNIT)
+ .attr('width', settings.LECTURE_COLUMN_WIDTH * settings.GRID_UNIT)
+ .attr('height', (size * settings.NODE_HEIGHT + (size + 1) * settings.LECTURE_PADDING) * settings.GRID_UNIT)
+ .attr('stroke-width', settings.STROKE_WIDTH)
+ .attr('fill', 'transparent')
+ .attr('stroke', 'black')
+
+ background.append('text')
+ .attr('x', dx + (settings.STROKE_WIDTH + settings.LECTURE_COLUMN_WIDTH * settings.GRID_UNIT) / 2)
+ .attr('y', dy)
+ .text('Past Topics')
+ .attr('text-anchor', 'middle')
+ .attr('dominant-baseline', 'hanging')
+ .style('font-size', settings.LECTURE_FONT_SIZE)
+
+ // Present subject column
+ background.append('rect')
+ .attr('x', dx + settings.STROKE_WIDTH / 2 + settings.LECTURE_COLUMN_WIDTH * settings.GRID_UNIT)
+ .attr('y', dy + settings.STROKE_WIDTH / 2 + settings.LECTURE_HEADER_HEIGHT * settings.GRID_UNIT)
+ .attr('width', settings.LECTURE_COLUMN_WIDTH * settings.GRID_UNIT)
+ .attr('height', (size * settings.NODE_HEIGHT + (size + 1) * settings.LECTURE_PADDING) * settings.GRID_UNIT)
+ .attr('stroke-width', settings.STROKE_WIDTH)
+ .attr('fill', 'transparent')
+ .attr('stroke', 'black')
+
+ background.append('text')
+ .attr('x', dx + (settings.STROKE_WIDTH + 3 * settings.LECTURE_COLUMN_WIDTH * settings.GRID_UNIT) / 2)
+ .attr('y', dy)
+ .text('This Lecture')
+ .attr('text-anchor', 'middle')
+ .attr('dominant-baseline', 'hanging')
+ .style('font-size', settings.LECTURE_FONT_SIZE)
+
+ // Future subject column
+ background.append('rect')
+ .attr('x', dx + settings.STROKE_WIDTH / 2 + 2 * settings.LECTURE_COLUMN_WIDTH * settings.GRID_UNIT)
+ .attr('y', dy + settings.STROKE_WIDTH / 2 + settings.LECTURE_HEADER_HEIGHT * settings.GRID_UNIT)
+ .attr('width', settings.LECTURE_COLUMN_WIDTH * settings.GRID_UNIT)
+ .attr('height', (size * settings.NODE_HEIGHT + (size + 1) * settings.LECTURE_PADDING) * settings.GRID_UNIT)
+ .attr('stroke-width', settings.STROKE_WIDTH)
+ .attr('fill', 'transparent')
+ .attr('stroke', 'black')
+
+ background.append('text')
+ .attr('x', dx + (settings.STROKE_WIDTH + 5 * settings.LECTURE_COLUMN_WIDTH * settings.GRID_UNIT) / 2)
+ .attr('y', dy)
+ .text('Future Topics')
+ .attr('text-anchor', 'middle')
+ .attr('dominant-baseline', 'hanging')
+ .style('font-size', settings.LECTURE_FONT_SIZE)
+ }
+
+ // Grid background
+ else {
+ background.append('rect')
+ .attr('fill', 'url(#grid)')
+ .attr('width', '100%')
+ .attr('height', '100%')
+ }
+ }
+
+ private clearBackground() {
+ d3.select('#background')
+ .selectAll('*')
+ .remove()
+ }
+
+ private setContent(nodes: NodeController[], relations: RelationController[], callback?: () => void) {
+ if (this.simulation === undefined)
+ throw new Error('GraphSVG not attached to DOM')
+
+ const content = d3.select('#content')
+ const lecture = this.lecture
+ const graphSVG = this
+
+ // Update Nodes
+ content.selectAll>('.node')
+ .data(nodes, node => node.uuid)
+ .join(
+ function(enter) {
+ return enter
+ .append('g')
+ .call(NodeSVG.create, graphSVG)
+ .call(NodeSVG.updateHighlight, lecture)
+ .style('opacity', 0)
+ },
+
+ function(update) {
+ return update
+ .call(NodeSVG.updateHighlight, lecture)
+ },
+
+ function(exit) {
+ return exit
+ .transition()
+ .duration(callback !== undefined ? settings.FADE_DURATION : 0)
+ .on('end', function() { d3.select(this).remove() }) // Use this instead of .remove() to circumvent pending transitions
+ .style('opacity', 0)
+ }
+ )
+ .transition()
+ .duration(callback !== undefined ? settings.FADE_DURATION : 0)
+ .style('opacity', 1)
+
+ // Update relations
+ content.selectAll>('.relation')
+ .data(relations, relation => relation.uuid)
+ .join(
+ function(enter) {
+ return enter
+ .append('line')
+ .call(RelationSVG.create)
+ .style('opacity', 0)
+ },
+
+ function(update) {
+ return update
+ },
+
+ function(exit) {
+ return exit
+ .transition()
+ .duration(callback !== undefined ? settings.FADE_DURATION : 0)
+ .on('end', function() { d3.select(this).remove() }) // Use this instead of .remove() to circumvent pending transitions
+ .style('opacity', 0)
+ }
+ )
+ .transition()
+ .duration(callback !== undefined ? settings.FADE_DURATION : 0)
+ .style('opacity', 1)
+
+ // Update simulation
+ const links = relations.map(relation => ({ source: relation.parent!, target: relation.child! }))
+ this.simulation
+ .nodes(nodes)
+ .force('link', d3.forceLink(links))
+
+ // Post-transition
+ if (callback) {
+ setTimeout(() => {
+ callback()
+ }, settings.FADE_DURATION)
+ }
+ }
+
+ private moveContent(nodes: SubjectController[], transform: (node: SubjectController) => void, callback?: () => void) {
+
+ // Buffer node positions
+ const buffers = nodes.map(node => ({ node, x: node.x, y: node.y }))
+
+ // Set node positions
+ nodes.forEach(transform)
+
+ // Update nodes
+ d3.select('#content')
+ .selectAll>('.node')
+ .call(NodeSVG.updatePosition, callback !== undefined)
+
+ // Restore node positions
+ for (const buffer of buffers) {
+ buffer.node.x = buffer.x
+ buffer.node.y = buffer.y
+ }
+
+ // Post-transition
+ if (callback) {
+ setTimeout(() => {
+ callback()
+ }, settings.ANIMATION_DURATION)
+ }
+ }
+
+ private restoreContent(callback?: () => void) {
+
+ // Update nodes
+ d3.select('#content')
+ .selectAll>('.node')
+ .call(NodeSVG.updatePosition, callback !== undefined)
+
+ // Post-transition
+ if (callback) {
+ setTimeout(() => {
+ callback()
+ }, settings.ANIMATION_DURATION)
+ }
+ }
+
+ private clearContent(callback?: () => void) {
+ d3.select('#content')
+ .selectAll('*')
+ .transition()
+ .duration(callback !== undefined ? settings.FADE_DURATION : 0)
+ .ease(d3.easeSinInOut)
+ .on('end', function() { d3.select(this).remove() }) // Use this instead of .remove() to circumvent pending transitions
+ .style('opacity', 0)
+
+ if (callback) {
+ setTimeout(() => {
+ callback()
+ }, settings.FADE_DURATION)
+ }
+ }
+
+ private DomainsView() {
+ const bbx = this.boundingBox(this.graph.domains)
+ this.moveCamera(bbx.x, bbx.y, bbx.k)
+ this.setBackground('domains')
+ this.setContent(this.graph.domains, this.graph.domain_relations)
+ }
+
+ private SubjectsView() {
+ const bbx = this.boundingBox(this.graph.subjects)
+ this.moveCamera(bbx.x, bbx.y, bbx.k)
+ this.setBackground('subjects')
+ this.setContent(this.graph.subjects, this.graph.subject_relations)
+ }
+
+ private LecturesView() {
+ this.setBackground('lectures')
+ this.moveCamera(0, 0, 1)
+
+ if (this.lecture !== null) {
+ this.setContent(this.lecture.subjects, this.lecture.relations)
+ this.moveContent(this.lecture.subjects, this.lectureTransform(0, 0))
+ }
+ }
+
+ private domainToSubject() {
+ const bbx = this.boundingBox(this.graph.subjects)
+
+ if (this.state === SVGState.broken) {
+ this.state = SVGState.animating
+ this.moveCamera(bbx.x, bbx.y, bbx.k)
+ this.setBackground('subjects')
+ this.setContent(this.graph.subjects, this.graph.subject_relations)
+ this.restoreContent()
+ this.state = this.interactive ? SVGState.dynamic : SVGState.static
+
+ return
+ }
+
+ this.state = SVGState.animating
+
+ this.setContent(this.graph.subjects, this.graph.subject_relations)
+ this.moveContent(this.graph.subjects, this.domainTransform)
+
+ this.moveCamera(bbx.x, bbx.y, bbx.k, ANIMATE)
+ this.restoreContent(() => {
+ this.state = this.interactive ? SVGState.dynamic : SVGState.static
+ })
+ }
+
+ private domainToLecture() {
+ if (this.lecture === null) {
+ this.setBackground('lectures')
+ this.clearContent()
+ this.state = SVGState.lecture
+
+ return
+ }
+
+ if (this.state === SVGState.broken) {
+ this.moveCamera(0, 0, 1)
+ this.setBackground('lectures')
+ this.setContent(this.lecture.subjects, this.lecture.relations)
+ this.moveContent(this.lecture.subjects, this.lectureTransform(0, 0))
+ this.state = SVGState.lecture
+
+ return
+ }
+
+ this.state = SVGState.animating
+
+ const bbx = this.boundingBox(this.graph.domains)
+ this.moveCamera(bbx.x, bbx.y, 1, ANIMATE)
+ this.setContent(this.lecture.subjects, this.lecture.relations)
+ this.moveContent(this.lecture.subjects, this.domainTransform)
+ this.moveContent(this.lecture.subjects, this.lectureTransform(bbx.x, bbx.y), () => {
+ this.setBackground('lectures')
+ this.state = SVGState.lecture
+ })
+ }
+
+ private subjectToDomain() {
+ const bbx = this.boundingBox(this.graph.domains)
+
+ if (this.state === SVGState.broken) {
+ this.state = SVGState.animating
+ this.moveCamera(bbx.x, bbx.y, bbx.k)
+ this.setContent(this.graph.domains, this.graph.domain_relations, () => {
+ this.state = this.interactive ? SVGState.dynamic : SVGState.static
+ })
+
+ return
+ }
+
+ this.state = SVGState.animating
+
+ this.moveCamera(bbx.x, bbx.y, bbx.k, ANIMATE)
+ this.moveContent(this.graph.subjects, this.domainTransform, () => {
+ this.setContent(this.graph.domains, this.graph.domain_relations, () => {
+ this.state = this.interactive ? SVGState.dynamic : SVGState.static
+ })
+ })
+ }
+
+ private subjectToLecture() {
+ if (this.lecture === null) {
+ this.setBackground('lectures')
+ this.clearContent()
+ this.state = SVGState.lecture
+
+ return
+ }
+
+ if (this.state === SVGState.broken) {
+ this.moveCamera(0, 0, 1)
+ this.setBackground('lectures')
+ this.setContent(this.lecture.subjects, this.lecture.relations)
+ this.moveContent(this.lecture.subjects, this.lectureTransform(0, 0))
+ this.state = SVGState.lecture
+
+ return
+ }
+
+ this.state = SVGState.animating
+
+ const bbx = this.boundingBox(this.graph.subjects)
+ this.moveCamera(bbx.x, bbx.y, 1, ANIMATE)
+ this.setContent(this.lecture.subjects, this.lecture.relations, () => {
+ this.moveContent(this.lecture!.subjects, this.lectureTransform(bbx.x, bbx.y), () => {
+ this.setBackground('lectures')
+ this.state = SVGState.lecture
+ })
+ })
+ }
+
+ private lectureToDomain() {
+ const bbx = this.boundingBox(this.graph.domains)
+
+ if (this.state === SVGState.broken || this.lecture === null) {
+ this.state = SVGState.animating
+ this.setBackground('domains')
+ this.moveCamera(bbx.x, bbx.y, bbx.k)
+ this.setContent(this.graph.domains, this.graph.domain_relations)
+ this.restoreContent()
+ this.state = this.interactive ? SVGState.dynamic : SVGState.static
+
+ return
+ }
+
+ this.state = SVGState.animating
+
+ this.panCamera(bbx.x, bbx.y)
+ this.setBackground('domains')
+ this.moveContent(this.lecture.subjects, this.lectureTransform(bbx.x, bbx.y))
+
+ this.zoomCamera(bbx.k, ANIMATE)
+ this.moveContent(this.lecture.subjects, this.domainTransform, () => {
+ this.setContent(this.graph.domains, this.graph.domain_relations, () => {
+ this.state = this.interactive ? SVGState.dynamic : SVGState.static
+ })
+ })
+ }
+
+ private lectureToSubject() {
+ const bbx = this.boundingBox(this.graph.subjects)
+
+ if (this.state === SVGState.broken || this.lecture === null) {
+ this.state = SVGState.animating
+ this.setBackground('subjects')
+ this.moveCamera(bbx.x, bbx.y, bbx.k)
+ this.setContent(this.graph.subjects, this.graph.subject_relations)
+ this.restoreContent()
+ this.state = this.interactive ? SVGState.dynamic : SVGState.static
+
+ return
+ }
+
+ this.state = SVGState.animating
+
+ this.panCamera(bbx.x, bbx.y)
+ this.setBackground('subjects')
+ this.moveContent(this.lecture.subjects, this.lectureTransform(bbx.x, bbx.y))
+
+ this.zoomCamera(bbx.k, ANIMATE)
+ this.restoreContent(() => {
+ this.setContent(this.graph.subjects, this.graph.subject_relations, () => {
+ this.state = this.interactive ? SVGState.dynamic : SVGState.static
+ })
+ })
+ }
+
+ private domainTransform(subject: SubjectController) {
+ subject.x = subject.domain!.x
+ subject.y = subject.domain!.y
+ }
+
+ private lectureTransform(x: number, y: number) {
+ const height = this.lecture?.max_height || 0
+ const past = this.lecture?.past_subjects
+ const present = this.lecture?.present_subjects
+ const future = this.lecture?.future_subjects
+
+ const dx = x - 3 * settings.LECTURE_COLUMN_WIDTH / 2
+ const dy = y - (settings.LECTURE_HEADER_HEIGHT + height * settings.NODE_HEIGHT + (height + 1) * settings.LECTURE_PADDING) / 2
+
+ return (subject: SubjectController) => {
+
+ // Set past subject positions to the right column
+ if (past?.includes(subject)) {
+ const index = past.indexOf(subject)
+ subject.x = dx + settings.STROKE_WIDTH / (2 * settings.GRID_UNIT) + settings.LECTURE_PADDING
+ subject.y = dy + settings.LECTURE_HEADER_HEIGHT + settings.STROKE_WIDTH / (2 * settings.GRID_UNIT) + (index + 1) * settings.LECTURE_PADDING + index * settings.NODE_HEIGHT
+ return
+ }
+
+ // Set present subject positions to the middle column
+ if (present?.includes(subject)) {
+ const index = present.indexOf(subject)
+ subject.x = dx + settings.STROKE_WIDTH / (2 * settings.GRID_UNIT) + settings.LECTURE_COLUMN_WIDTH + settings.LECTURE_PADDING
+ subject.y = dy + settings.LECTURE_HEADER_HEIGHT + settings.STROKE_WIDTH / (2 * settings.GRID_UNIT) + (index + 1) * settings.LECTURE_PADDING + index * settings.NODE_HEIGHT
+ return
+ }
+
+ // Set future subject positions to the left column
+ if (future?.includes(subject)) {
+ const index = future.indexOf(subject)
+ subject.x = dx + settings.STROKE_WIDTH / (2 * settings.GRID_UNIT) + 2 * settings.LECTURE_COLUMN_WIDTH + settings.LECTURE_PADDING
+ subject.y = dy + settings.LECTURE_HEADER_HEIGHT + settings.STROKE_WIDTH / (2 * settings.GRID_UNIT) + (index + 1) * settings.LECTURE_PADDING + index * settings.NODE_HEIGHT
+ return
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/lib/scripts/svg/NodeSVG.ts b/src/lib/scripts/svg/NodeSVG.ts
new file mode 100644
index 00000000..48d62446
--- /dev/null
+++ b/src/lib/scripts/svg/NodeSVG.ts
@@ -0,0 +1,188 @@
+
+// External imports
+import * as d3 from 'd3'
+
+// Internal imports
+import * as settings from '$scripts/settings'
+import { NODE_STYLES } from '$scripts/settings'
+
+import {
+ GraphSVG,
+ RelationSVG,
+ SVGState
+} from '$scripts/svg'
+
+import {
+ NodeController,
+ DomainController,
+ SubjectController,
+ RelationController,
+ LectureController
+} from '$scripts/controllers'
+
+// Exports
+export { NodeSVG }
+
+
+// --------------------> Classes
+
+type NodeSelection = d3.Selection, d3.BaseType, unknown>
+
+class NodeSVG {
+ static create(selection: NodeSelection, graphSVG: GraphSVG) {
+
+ // Node attributes
+ selection
+ .attr('id', node => node.uuid)
+ .attr('class', 'node fixed')
+ .attr('transform', node => `translate(
+ ${node.x * settings.GRID_UNIT},
+ ${node.y * settings.GRID_UNIT}
+ )`)
+
+ // Node outline
+ selection.append('path')
+ .attr('stroke-width', settings.STROKE_WIDTH)
+ .attr('stroke', node => NODE_STYLES[node.style!].stroke)
+ .attr('fill', node => NODE_STYLES[node.style!].fill)
+ .attr('d', node => NODE_STYLES[node.style!].path)
+
+ // Node text
+ selection.append('text')
+ .text(node => node.name!)
+ .style('text-anchor', 'middle')
+ .style('dominant-baseline', 'middle')
+ .style('font-size', settings.NODE_FONT_SIZE)
+
+ // Wrap text
+ selection
+ .selectAll('text')
+ .each(function() {
+ const max_width = (settings.NODE_WIDTH - 2 * settings.NODE_PADDING) * settings.GRID_UNIT
+ const middle_height = settings.NODE_HEIGHT / 2 * settings.GRID_UNIT
+ const middle_width = settings.NODE_WIDTH / 2 * settings.GRID_UNIT
+
+ // Select elements
+ const element = d3.select(this)
+ const text = element.text()
+ const words = text.split(/\s+/)
+ element.text(null)
+
+ // Make tspan
+ let tspan = element
+ .append('tspan')
+ .attr('x', middle_width)
+
+ // Get longest word
+ const longest = words.reduce((a, b) => a.length > b.length ? a : b)
+ tspan.text(longest)
+
+ // Scale font size
+ const scale = Math.min(1, max_width / tspan.node()!.getComputedTextLength())
+ const font_size = settings.NODE_FONT_SIZE * scale
+ element.attr('font-size', font_size)
+
+ // Wrap text
+ let line_count = 0
+ let line: string[] = []
+ for (const word of words) {
+ line.push(word)
+ tspan.text(line.join(' '))
+ if (tspan.node()!.getComputedTextLength() > max_width) {
+ line.pop()
+ tspan.text(line.join(' '))
+ line = [word]
+ tspan = element.append('tspan')
+ .attr('x', middle_width)
+ .attr('y', 0)
+ .attr('dy', ++line_count * 1.1 + 'em')
+ .text(word)
+ }
+ }
+
+ // Center vertically
+ element.selectAll('tspan')
+ .attr('y', middle_height - font_size * line_count * 1.1 / 2)
+ })
+
+ // Drag behaviour
+ selection.call(
+ d3.drag>()
+ .filter(event => graphSVG.state === SVGState.dynamic)
+ .on('start', function() {
+ const selection = d3.select>(this)
+ selection.call(NodeSVG.setFixed, true)
+ })
+ .on('drag', function(event, node) {
+ const selection = d3.select>(this)
+ node.x = node.x + event.dx / settings.GRID_UNIT
+ node.y = node.y + event.dy / settings.GRID_UNIT
+ node.fx = node.x
+ node.fy = node.y
+
+ NodeSVG.updatePosition(selection)
+ graphSVG.microwaveSimulation()
+ })
+ .on('end', async function(_ ,node) {
+ const selection = d3.select>(this)
+ node.x = Math.round(node.x)
+ node.y = Math.round(node.y)
+ node.fx = node.x
+ node.fy = node.y
+
+ NodeSVG.updatePosition(selection)
+ await node.save()
+ })
+ )
+ }
+
+ static updatePosition(selection: NodeSelection, animated: boolean = false) {
+ const content = d3.select('g#content')
+
+ // Raise nodes
+ selection.raise()
+
+ // Update node position
+ selection
+ .transition()
+ .duration(animated ? settings.ANIMATION_DURATION : 0)
+ .ease(d3.easeSinInOut)
+ .attr('transform', node => `translate(
+ ${node.x * settings.GRID_UNIT},
+ ${node.y * settings.GRID_UNIT}
+ )`)
+
+ // Update relations
+ selection.each(function(node) {
+ content.selectAll>('.relation')
+ .filter(relation => relation.parent === node || relation.child === node)
+ .call(RelationSVG.update, animated)
+ })
+ }
+
+ static updateHighlight(selection: NodeSelection, lecture: LectureController | null) {
+ selection
+ .each(function(node) {
+ const highlight =
+ node instanceof DomainController && lecture?.present_subjects.some(subject => subject.domain === node) ||
+ node instanceof SubjectController && lecture?.present_subjects.includes(node)
+
+ d3.select(this)
+ .attr('filter', highlight ? 'url(#highlight)' : null)
+ })
+ }
+
+ static setFixed(selection: NodeSelection, fixed: boolean) {
+ selection
+ .classed('fixed', fixed)
+ .attr('stroke-dasharray', fixed ? null : settings.STROKE_DASHARRAY)
+ .each(node => {
+ node.x = fixed ? Math.round(node.x) : node.x
+ node.y = fixed ? Math.round(node.y) : node.y
+ node.fx = fixed ? node.x : undefined
+ node.fy = fixed ? node.y : undefined
+ })
+
+ NodeSVG.updatePosition(selection)
+ }
+}
diff --git a/src/lib/scripts/svg/OverlaySVG.ts b/src/lib/scripts/svg/OverlaySVG.ts
new file mode 100644
index 00000000..614248b6
--- /dev/null
+++ b/src/lib/scripts/svg/OverlaySVG.ts
@@ -0,0 +1,110 @@
+
+// Internal imports
+import * as settings from '$scripts/settings'
+import { GraphSVG } from '$scripts/svg'
+
+// Exports
+export { OverlaySVG }
+
+
+// --------------------> Classes
+
+type OverlaySelection = d3.Selection
+
+class OverlaySVG {
+ static reset(selection: OverlaySelection) {
+ selection
+ .interrupt()
+ .attr('class', null)
+ .style('opacity', 1)
+ .style('pointer-events', 'none')
+ .selectAll('*')
+ .remove()
+ }
+
+ static broken(selection: OverlaySelection) {
+ if (selection.classed('broken')) return
+ OverlaySVG.reset(selection)
+
+ selection
+ .attr('class', 'broken')
+
+ selection.append('rect')
+ .attr('width', '100%')
+ .attr('height', '100%')
+ .attr('fill', 'white')
+
+ const text = selection.append('text')
+ text.append('tspan')
+ .text('Oops!')
+ .attr('x', '50%')
+ .attr('y', '50%')
+ .attr('font-size', settings.OVERLAY_BIG_FONT)
+ .attr('dominant-baseline', 'middle')
+ .attr('text-anchor', 'middle')
+ .attr('fill', 'black')
+
+ text.append('tspan')
+ .text('There are outstanding errors in this graph')
+ .attr('x', '50%')
+ .attr('y', '50%')
+ .attr('dy', settings.OVERLAY_BIG_FONT)
+ .attr('font-size', settings.OVERLAY_SMALL_FONT)
+ .attr('dominant-baseline', 'middle')
+ .attr('text-anchor', 'middle')
+ .attr('fill', 'black')
+ }
+
+ static shiftWarning(selection: OverlaySelection, graphSVG: GraphSVG) {
+ if (!selection.classed('shift-scroll')) {
+ OverlaySVG.reset(selection)
+
+ selection
+ .attr('class', 'shift-scroll')
+
+ selection.append('rect')
+ .attr('width', '100%')
+ .attr('height', '100%')
+ .attr('background-color', 'black')
+ .style('opacity', settings.OVERLAY_OPACITY)
+
+ const text = selection.append('text')
+ text.append('tspan')
+ .text('Shift + Scroll to zoom')
+ .attr('x', '50%')
+ .attr('y', '50%')
+ .attr('font-size', settings.OVERLAY_BIG_FONT)
+ .attr('dominant-baseline', 'middle')
+ .attr('text-anchor', 'middle')
+ .attr('fill', 'white')
+
+ text.append('tspan')
+ .text('disable this')
+ .attr('x', '50%')
+ .attr('y', '50%')
+ .attr('dy', settings.OVERLAY_BIG_FONT)
+ .attr('font-size', settings.OVERLAY_SMALL_FONT)
+ .attr('text-decoration', 'underline')
+ .attr('dominant-baseline', 'middle')
+ .attr('text-anchor', 'middle')
+ .style('cursor', 'pointer')
+ .attr('fill', 'white')
+ .on('click', () => {
+ graphSVG.shift_zoom = false
+ OverlaySVG.reset(selection)
+ })
+ }
+
+ selection
+ .interrupt()
+ .style('opacity', 1)
+ .style('pointer-events', 'all')
+ .transition()
+ .duration(settings.OVERLAY_FADE_OUT)
+ .delay(settings.OVERLAY_LINGER)
+ .style('opacity', 0)
+ .on('end', () => {
+ OverlaySVG.reset(selection)
+ })
+ }
+}
\ No newline at end of file
diff --git a/src/lib/scripts/svg/RelationSVG.ts b/src/lib/scripts/svg/RelationSVG.ts
new file mode 100644
index 00000000..06ccc7da
--- /dev/null
+++ b/src/lib/scripts/svg/RelationSVG.ts
@@ -0,0 +1,103 @@
+
+// External imports
+import * as d3 from 'd3'
+
+// Internal imports
+import * as settings from '$scripts/settings'
+import { NODE_STYLES } from '$scripts/settings'
+
+import {
+ DomainController,
+ SubjectController,
+ RelationController
+} from '$scripts/controllers'
+
+// Exports
+export { RelationSVG }
+
+
+// --------------------> Classes
+
+type RelationSelection = d3.Selection, d3.BaseType, unknown>
+
+class RelationSVG {
+ static create(selection: RelationSelection) {
+ selection
+ .attr('id', relation => relation.uuid)
+ .attr('class', 'relation')
+ .attr('stroke-width', settings.STROKE_WIDTH)
+ .attr('stroke', relation => NODE_STYLES[relation.parent!.style!].stroke)
+ .attr('fill', relation => NODE_STYLES[relation.parent!.style!].stroke)
+ .attr('marker-end', 'url(#arrowhead)')
+ .call(RelationSVG.update)
+ }
+
+ static update(selection: RelationSelection, animated: boolean = false) {
+
+ // Lower relations
+ selection.lower()
+
+ // Update relations
+ selection.each(function(relation) {
+ const line = d3.select(this)
+
+ /* We are calculating the line connecting two nodes.
+ * It should start at the center of the start node and end at the BOUNDS of the end node.
+ * We first find some properties of the nodes: their size, center, and the difference between them.
+ * As the calculation of the line depends on the placement of the start node relative to the end node,
+ * we split the plane in 4 quadrants, originating from the start node, following its diagonals.
+ * We then use simple geometry, based on the quadrant the end node resides in, to calculate the bounds of the end node.
+ * The final line is a line from the center of the start node to the calculated bounds of the end node.
+ */
+
+ // Half the width and height of the nodes
+ const halfWidth = settings.NODE_WIDTH / 2 + settings.NODE_MARGIN
+ const halfHeight = settings.NODE_HEIGHT / 2 + settings.NODE_MARGIN
+
+ // Center of the nodes
+ const cxStart = relation.parent!.x - settings.NODE_MARGIN + halfWidth
+ const cyStart = relation.parent!.y - settings.NODE_MARGIN + halfHeight
+ const cxEnd = relation.child!.x - settings.NODE_MARGIN + halfWidth
+ const cyEnd = relation.child!.y - settings.NODE_MARGIN + halfHeight
+
+ // Difference between the centers
+ const dx = cxEnd - cxStart
+ const dy = cyEnd - cyStart
+
+ // Check for overlap
+ if (Math.abs(dx) < halfWidth && Math.abs(dy) < halfHeight) {
+ line.transition()
+ .duration(animated ? settings.ANIMATION_DURATION : 0)
+ .ease(d3.easeSinInOut)
+ .attr('x1', cxStart * settings.GRID_UNIT)
+ .attr('y1', cyStart * settings.GRID_UNIT)
+ .attr('x2', cxEnd * settings.GRID_UNIT)
+ .attr('y2', cyEnd * settings.GRID_UNIT)
+
+ return
+ }
+
+ // Bounds
+ const ratio = halfHeight / halfWidth
+ const posBound = ratio * dx + cyStart
+ const negBound = -ratio * dx + cyStart
+
+ const sign = cyEnd > negBound ? -1 : 1
+ const vertQuad = (cyEnd > posBound) == (cyEnd > negBound)
+
+ // Final line
+ const x1 = cxStart
+ const y1 = cyStart
+ const x2 = cxEnd + sign * (vertQuad ? halfHeight * dx / dy : halfWidth)
+ const y2 = cyEnd + sign * (vertQuad ? halfHeight : halfWidth * dy / dx)
+
+ line.transition()
+ .duration(animated ? settings.ANIMATION_DURATION : 0)
+ .ease(d3.easeSinInOut)
+ .attr('x1', x1 * settings.GRID_UNIT)
+ .attr('y1', y1 * settings.GRID_UNIT)
+ .attr('x2', x2 * settings.GRID_UNIT)
+ .attr('y2', y2 * settings.GRID_UNIT)
+ })
+ }
+}
diff --git a/src/lib/scripts/svg/index.ts b/src/lib/scripts/svg/index.ts
new file mode 100644
index 00000000..1f113e5c
--- /dev/null
+++ b/src/lib/scripts/svg/index.ts
@@ -0,0 +1,5 @@
+
+export { GraphSVG, SVGState } from './GraphSVG'
+export { NodeSVG } from './NodeSVG'
+export { RelationSVG } from './RelationSVG'
+export { OverlaySVG } from './OverlaySVG'
\ No newline at end of file
diff --git a/src/lib/scripts/types/editor.ts b/src/lib/scripts/types/editor.ts
index 6db7b5e9..9410cd68 100644
--- a/src/lib/scripts/types/editor.ts
+++ b/src/lib/scripts/types/editor.ts
@@ -1,2 +1,12 @@
-export type EditorView = 'domains' | 'subjects' | 'lectures'
\ No newline at end of file
+export type EditorType = 'nodes' | 'layout'
+
+export function validEditorType(type: any): type is EditorType {
+ return type === 'nodes' || type === 'layout'
+}
+
+export type EditorView = 'domains' | 'subjects' | 'lectures'
+
+export function validEditorView(view: any): view is EditorView {
+ return view === 'domains' || view === 'subjects' || view === 'lectures'
+}
\ No newline at end of file
diff --git a/src/lib/scripts/types/index.ts b/src/lib/scripts/types/index.ts
index 0fed3872..69fe8d85 100644
--- a/src/lib/scripts/types/index.ts
+++ b/src/lib/scripts/types/index.ts
@@ -15,6 +15,11 @@ export {
validPermission
} from './permissions'
+export {
+ validEditorType,
+ validEditorView
+} from './editor'
+
export { validDomainStyle } from './styles'
export type {
@@ -44,6 +49,10 @@ export type {
Permission
} from './permissions'
+export type {
+ EditorView,
+ EditorType
+} from './editor'
+
export type { DropdownOption } from './dropdown'
export type { DomainStyle } from './styles'
-export type { EditorView } from './editor'
diff --git a/src/routes/api/graph/[id]/reorder/+server.ts b/src/routes/api/graph/[id]/reorder/domains/+server.ts
similarity index 89%
rename from src/routes/api/graph/[id]/reorder/+server.ts
rename to src/routes/api/graph/[id]/reorder/domains/+server.ts
index 2d43a2d3..7fce3d61 100644
--- a/src/routes/api/graph/[id]/reorder/+server.ts
+++ b/src/routes/api/graph/[id]/reorder/domains/+server.ts
@@ -17,7 +17,7 @@ async function PUT({ request }) {
}
// Reorder data
- return await GraphHelper.reorder(domain_ids)
+ return await GraphHelper.reorderDomains(domain_ids)
.then(
() => new Response(null, { status: 200 }),
error => new Response(error, { status: 400 })
diff --git a/src/routes/api/graph/[id]/reorder/lectures/+server.ts b/src/routes/api/graph/[id]/reorder/lectures/+server.ts
new file mode 100644
index 00000000..01f55877
--- /dev/null
+++ b/src/routes/api/graph/[id]/reorder/lectures/+server.ts
@@ -0,0 +1,25 @@
+
+// Internal dependencies
+import { GraphHelper } from '$scripts/helpers'
+
+// Exports
+export { PUT }
+
+
+// --------------------> API Endpoints
+
+async function PUT({ request }) {
+
+ // Retrieve data
+ const { lecture_ids } = await request.json()
+ if (!lecture_ids) {
+ return new Response('Missing data', { status: 400 })
+ }
+
+ // Reorder data
+ return await GraphHelper.reorderLectures(lecture_ids)
+ .then(
+ () => new Response(null, { status: 200 }),
+ error => new Response(error, { status: 400 })
+ )
+}
diff --git a/src/routes/app/course/[course]/[link]/+page.server.ts b/src/routes/app/course/[course]/[link]/+page.server.ts
new file mode 100644
index 00000000..295dd85b
--- /dev/null
+++ b/src/routes/app/course/[course]/[link]/+page.server.ts
@@ -0,0 +1,28 @@
+
+// External dependencies
+import type { PageServerLoad } from './$types'
+
+// Internal dependencies
+import {
+ GraphHelper,
+ LinkHelper
+} from '$scripts/helpers'
+
+// Load
+export const load: PageServerLoad = async ({ params }) => {
+ const course_code = params.course
+ const link_name = params.link
+
+ // Get data from the database
+ const graph = await LinkHelper.getGraphFromCourseAndName(course_code, link_name, 'domains', 'subjects', 'lectures')
+
+ // Start data streams
+ const domains = GraphHelper.getDomains(graph.id, 'graph', 'parents', 'children', 'subjects')
+ .catch(error => { throw new Error(error) })
+ const subjects = GraphHelper.getSubjects(graph.id, 'graph', 'parents', 'children', 'domain', 'lectures')
+ .catch(error => { throw new Error(error) })
+ const lectures = GraphHelper.getLectures(graph.id, 'graph', 'subjects')
+ .catch(error => { throw new Error(error) })
+
+ return { graph, domains, subjects, lectures }
+}
diff --git a/src/routes/app/course/[course]/[link]/+page@.svelte b/src/routes/app/course/[course]/[link]/+page@.svelte
new file mode 100644
index 00000000..6698b6f0
--- /dev/null
+++ b/src/routes/app/course/[course]/[link]/+page@.svelte
@@ -0,0 +1,180 @@
+
+
+
+
+
+
+
+{#await revive()}
+
+{:then}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{/await}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/routes/app/course/[course]/overview/+page.server.ts b/src/routes/app/course/[course]/overview/+page.server.ts
index 156f1280..93ed3d26 100644
--- a/src/routes/app/course/[course]/overview/+page.server.ts
+++ b/src/routes/app/course/[course]/overview/+page.server.ts
@@ -20,11 +20,11 @@ export const load: PageServerLoad = async ({ params }) => {
.catch(error => { throw new Error(error) })
const links = CourseHelper.getLinks(course_id, 'course', 'graph')
.catch(error => { throw new Error(error) })
- const domains = asyncFlatmap(graphs, graph => GraphHelper.getDomains(graph.id, 'parents', 'children'))
+ const domains = asyncFlatmap(graphs, graph => GraphHelper.getDomains(graph.id, 'graph', 'parents', 'children', 'subjects'))
.catch(error => { throw new Error(error) })
- const subjects = asyncFlatmap(graphs, graph => GraphHelper.getSubjects(graph.id, 'parents', 'children', 'domain'))
+ const subjects = asyncFlatmap(graphs, graph => GraphHelper.getSubjects(graph.id, 'graph', 'parents', 'children', 'domain', 'lectures'))
.catch(error => { throw new Error(error) })
- const lectures = asyncFlatmap(graphs, graph => GraphHelper.getLectures(graph.id, 'subjects'))
+ const lectures = asyncFlatmap(graphs, graph => GraphHelper.getLectures(graph.id, 'graph', 'subjects'))
.catch(error => { throw new Error(error) })
const courses = asyncConcat(course, CourseHelper.getAll())
.catch(error => { throw new Error(error) })
diff --git a/src/routes/app/course/[course]/overview/GraphCard.svelte b/src/routes/app/course/[course]/overview/GraphCard.svelte
index 42124d13..4172ecb5 100644
--- a/src/routes/app/course/[course]/overview/GraphCard.svelte
+++ b/src/routes/app/course/[course]/overview/GraphCard.svelte
@@ -71,6 +71,8 @@
}
// Create graph
+ this.disabled = true
+ graph_modal = graph_modal // Trigger reactivity
await GraphController.create($course.cache, $course, this.trimmed_name)
$course = $course // Trigger reactivity
graph_modal.hide()
@@ -94,7 +96,7 @@