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 @@