/
githubmirror
/
oppia
Обзор
Документация
Войти
/
githubmirror
/
oppia
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
extensions/interactions/GraphInput/directives/graph-viz.component.ts
808 строк
23 KB
Pritesh Jogdhankar
Fix part of #23496: Enforce strict typescript checks for group 14 files. (#24381)
28 янв 2026, 16:47
Не верифицирован
28 янв 2026, 16:47
eda9913
Код
Авторство
О чём код?
// Copyright 2019 The Oppia Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS-IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. /** * @fileoverview Directive for the graph-viz. * * IMPORTANT NOTE: The naming convention for customization args that are passed * into the directive is: the name of the parameter, followed by 'With', * followed by the name of the arg. */ import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, EventEmitter, HostListener, Input, OnInit, Output, ViewChild, } from '@angular/core'; import isNumber from 'lodash/isNumber'; import {GraphAnswer} from 'interactions/answer-defs'; import {InteractionsExtensionsConstants} from 'interactions/interactions-extension.constants'; import {PlayerPositionService} from 'pages/exploration-player-page/services/player-position.service'; import {Subscription} from 'rxjs'; import {DeviceInfoService} from 'services/contextual/device-info.service'; import {FocusManagerService} from 'services/stateful/focus-manager.service'; import {UtilsService} from 'services/utils.service'; import {EdgeCentre, GraphDetailService} from './graph-detail.service'; import {I18nLanguageCodeService} from 'services/i18n-language-code.service'; const debounce = (delay = 5): MethodDecorator => { return ( target: object, propertyKey: string | symbol, descriptor: PropertyDescriptor ): void => { const original = descriptor.value as (...args: unknown[]) => unknown; const key = `__timeout__${String(propertyKey)}`; descriptor.value = function ( this: Record<string, unknown>, ...args: unknown[] ) { clearTimeout(this[key] as number); this[key] = setTimeout(() => original.apply(this, args), delay); }; }; }; interface GraphButton { text: string; description: string; mode: number; } interface GraphOption { text: string; option: string; } @Component({ selector: 'graph-viz', templateUrl: './graph-viz.component.html', styleUrls: [], }) export class GraphVizComponent implements OnInit, AfterViewInit { @Input() graph!: GraphAnswer; @Input() canAddVertex!: boolean; @Input() canDeleteVertex!: boolean; @Input() canMoveVertex!: boolean; @Input() canEditVertexLabel!: boolean; @Input() canAddEdge!: boolean; @Input() canDeleteEdge!: boolean; @Input() canEditEdgeWeight!: boolean; @Input() interactionIsActive!: boolean; @Input() canEditOptions!: boolean; @ViewChild('dotCursor') dotCursor!: ElementRef<HTMLDivElement>; @ViewChild('graphArea') graphArea!: ElementRef<HTMLElement>; dotCursorCoordinateX: number = 0; dotCursorCoordinateY: number = 0; @Output() graphChange: EventEmitter<GraphAnswer> = new EventEmitter(); usingMobileDevice: boolean = false; helpText: string | null = ''; _MODES = { MOVE: 0, ADD_EDGE: 1, ADD_VERTEX: 2, DELETE: 3, }; // Styling functions. DELETE_COLOR = 'red'; HOVER_COLOR = 'aqua'; SELECT_COLOR = 'orange'; DEFAULT_COLOR = 'black'; state = { currentMode: this._MODES.MOVE as number | null, // Vertex, edge, mode button, label currently being hovered over. hoveredVertex: null as number | null, hoveredEdge: null as number | null, hoveredModeButton: null, // If in ADD_EDGE mode, source vertex of the new edge, if it // exists. addEdgeVertex: null as number | null, // Currently dragged vertex. currentlyDraggedVertex: null as number | null, // Selected vertex for editing label. selectedVertex: null as number | null, // Selected edge for editing weight. selectedEdge: null as number | null, // Mouse position in SVG coordinates. mouseX: 0, mouseY: 0, // Original position of dragged vertex. vertexDragStartX: 0, vertexDragStartY: 0, // Original position of mouse when dragging started. mouseDragStartX: 0, mouseDragStartY: 0, }; selectedEdgeWeightValue!: string | number; buttons: GraphButton[] = []; private vizContainer!: SVGSVGElement[]; componentSubscriptions: Subscription = new Subscription(); shouldShowWrongWeightWarning!: boolean; VERTEX_RADIUS_PX!: number; EDGE_WIDTH_PX!: number; graphOptions!: GraphOption[]; svgViewBox!: string; constructor( private changeDetectorRef: ChangeDetectorRef, private deviceInfoService: DeviceInfoService, private element: ElementRef, private focusManagerService: FocusManagerService, private graphDetailService: GraphDetailService, private i18nLanguageCodeService: I18nLanguageCodeService, private playerPositionService: PlayerPositionService, private utilsService: UtilsService ) {} ngOnInit(): void { this.componentSubscriptions.add( this.playerPositionService.onNewCardAvailable.subscribe( () => (this.state.currentMode = null) ) ); this.VERTEX_RADIUS_PX = this.graphDetailService.VERTEX_RADIUS_PX; this.EDGE_WIDTH_PX = this.graphDetailService.EDGE_WIDTH_PX; this.selectedEdgeWeightValue = 0; this.shouldShowWrongWeightWarning = false; this.usingMobileDevice = !!this.deviceInfoService.isMobileDevice(); } ngAfterViewInit(): void { this.vizContainer = this.element.nativeElement.querySelectorAll( '.oppia-graph-viz-svg' ); this.graphOptions = [ { text: 'Labeled', option: 'isLabeled', }, { text: 'Directed', option: 'isDirected', }, { text: 'Weighted', option: 'isWeighted', }, ]; this.helpText = null; const svgContainer = this.element.nativeElement.querySelectorAll( '.oppia-graph-viz-svg' )[0]; const boundingBox = svgContainer.getBBox(); const viewBoxHeight = Math.max( boundingBox.height + boundingBox.y, svgContainer.getAttribute('height') ); this.svgViewBox = `0 0 ${svgContainer.width.baseVal.value} ${viewBoxHeight}`; // Initial value of SVG view box. if (this.interactionIsActive) { this.init(); } this.changeDetectorRef.detectChanges(); } handleKeyDown(event: KeyboardEvent): void { if (this.state.currentMode === 2 && !this.isButtonOnTopOfDot()) { const stepSizeInPx = 10; switch (event.key) { case 'ArrowLeft': event.preventDefault(); this.dotCursorCoordinateX -= stepSizeInPx; break; case 'ArrowUp': event.preventDefault(); this.dotCursorCoordinateY -= stepSizeInPx; break; case 'ArrowRight': event.preventDefault(); this.dotCursorCoordinateX += stepSizeInPx; break; case 'ArrowDown': event.preventDefault(); this.dotCursorCoordinateY += stepSizeInPx; break; case 'Enter': this.onClickGraphSVG(); } const dot = this.dotCursor.nativeElement; dot.style.top = this.dotCursorCoordinateY + 'px'; dot.style.left = this.dotCursorCoordinateX + 'px'; } } getEdgeColor(index: number): string { if (!this.interactionIsActive) { return this.DEFAULT_COLOR; } if ( this.state.currentMode === this._MODES.DELETE && index === this.state.hoveredEdge && this.canDeleteEdge ) { return this.DELETE_COLOR; } else if (index === this.state.hoveredEdge) { return this.HOVER_COLOR; } else if (this.state.selectedEdge === index) { return this.SELECT_COLOR; } else { return this.DEFAULT_COLOR; } } isLanguageRTL(): boolean { return this.i18nLanguageCodeService.isCurrentLanguageRTL(); } getVertexColor(index: number): string { if (!this.interactionIsActive) { return this.DEFAULT_COLOR; } if ( this.state.currentMode === this._MODES.DELETE && index === this.state.hoveredVertex && this.canDeleteVertex ) { return this.DELETE_COLOR; } else if (index === this.state.currentlyDraggedVertex) { return this.HOVER_COLOR; } else if (index === this.state.hoveredVertex) { return this.HOVER_COLOR; } else if (this.state.selectedVertex === index) { return this.SELECT_COLOR; } else { return this.DEFAULT_COLOR; } } getDirectedEdgeArrowPoints(index: number): string { return this.graphDetailService.getDirectedEdgeArrowPoints( this.graph, index ); } getEdgeCentre(index: number): EdgeCentre { return this.graphDetailService.getEdgeCentre(this.graph, index); } mousemoveGraphSVG(event: MouseEvent): void { if (!this.interactionIsActive) { return; } // Note: Transform client (X, Y) to SVG (X, Y). This has to be // done so that changes due to viewBox attribute are // propagated nicely. const pt = this.vizContainer[0].createSVGPoint(); pt.x = event.clientX; pt.y = event.clientY; const ctm = this.vizContainer[0].getScreenCTM(); if (!ctm) { return; } const svgp = pt.matrixTransform(ctm.inverse()); this.state.mouseX = svgp.x; this.state.mouseY = svgp.y; // The condition `currentMode === 2` signifies that // the "add node" option in the graph is active. if (this.state.currentMode === 2) { const dot = document.querySelector( '.oppia-cursor-for-add-node' ) as HTMLDivElement; const graphAreaRect = this.graphArea.nativeElement.getBoundingClientRect(); this.dotCursorCoordinateX = event.clientX - graphAreaRect.left; this.dotCursorCoordinateY = event.clientY - graphAreaRect.top; dot.style.top = this.dotCursorCoordinateY + 'px'; dot.style.left = this.dotCursorCoordinateX + 'px'; } // We use vertexDragStartX/Y and mouseDragStartX/Y to make // mouse-dragging by label more natural, by moving the vertex // according to the difference from the original position. // Otherwise, mouse-dragging by label will make the vertex // awkwardly jump to the mouse. if ( this.state.currentlyDraggedVertex !== null && this.state.mouseX > InteractionsExtensionsConstants.GRAPH_INPUT_LEFT_MARGIN ) { this.graph.vertices[this.state.currentlyDraggedVertex].x = this.state.vertexDragStartX + (this.state.mouseX - this.state.mouseDragStartX); this.graph.vertices[this.state.currentlyDraggedVertex].y = this.state.vertexDragStartY + (this.state.mouseY - this.state.mouseDragStartY); } } isButtonOnTopOfDot(): boolean { const dotElement = document.querySelector( '.oppia-cursor-for-add-node' ) as HTMLDivElement; const buttonElements = document.querySelectorAll('.graph-button'); if (!dotElement || !buttonElements || buttonElements.length === 0) { return false; } const dotRect = dotElement.getBoundingClientRect(); const dotTop = dotRect.top; const dotBottom = dotRect.bottom; const dotLeft = dotRect.left; const dotRight = dotRect.right; for (let i = 0; i < buttonElements.length; i++) { const buttonRect = buttonElements[i].getBoundingClientRect(); const buttonTop = buttonRect.top; const buttonBottom = buttonRect.bottom; const buttonLeft = buttonRect.left; const buttonRight = buttonRect.right; if ( buttonTop <= dotTop && buttonBottom >= dotBottom && buttonLeft <= dotLeft && buttonRight >= dotRight ) { buttonElements[i].dispatchEvent(new MouseEvent('click')); return true; } } return false; } onClickGraphSVG(): void { if (!this.interactionIsActive) { return; } if ( this.state.currentMode === this._MODES.ADD_VERTEX && this.canAddVertex && !this.isButtonOnTopOfDot() ) { this.graph.vertices.push({ x: this.dotCursorCoordinateX, y: this.dotCursorCoordinateY, label: '', }); this.graphChange.emit(this.graph); } if (this.state.hoveredVertex === null) { this.state.selectedVertex = null; } if (this.state.hoveredEdge === null) { this.state.selectedEdge = null; } } initButtons(): void { this.buttons = []; if (this.canMoveVertex) { this.buttons.push({ text: '\uF0B2', description: 'I18N_INTERACTIONS_GRAPH_MOVE', mode: this._MODES.MOVE, }); } if (this.canAddEdge) { this.buttons.push({ text: '\uF0C1', description: 'I18N_INTERACTIONS_GRAPH_ADD_EDGE', mode: this._MODES.ADD_EDGE, }); } if (this.canAddVertex) { this.buttons.push({ text: '\uF067', description: 'I18N_INTERACTIONS_GRAPH_ADD_NODE', mode: this._MODES.ADD_VERTEX, }); } if (this.canDeleteVertex || this.canDeleteEdge) { this.buttons.push({ text: '\uF068', description: 'I18N_INTERACTIONS_GRAPH_DELETE', mode: this._MODES.DELETE, }); } } init(): void { this.initButtons(); this.state.currentMode = this.buttons[0].mode; if (this.usingMobileDevice) { if (this.state.currentMode === this._MODES.ADD_EDGE) { this.helpText = 'I18N_INTERACTIONS_GRAPH_EDGE_INITIAL_HELPTEXT'; } else if (this.state.currentMode === this._MODES.MOVE) { this.helpText = 'I18N_INTERACTIONS_GRAPH_MOVE_INITIAL_HELPTEXT'; } else { this.helpText = ''; } } else { this.helpText = ''; } } toggleGraphOption(option: 'isDirected' | 'isWeighted' | 'isLabeled'): void { // Handle the case when we have two edges s -> d and d -> s. if (option === 'isDirected' && this.graph[option]) { this._deleteRepeatedUndirectedEdges(); } this.graph[option] = !this.graph[option]; this.graphChange.emit(this.graph); } setMode(mode: number): void { this.state.currentMode = mode; if (this.usingMobileDevice) { if (this.state.currentMode === this._MODES.ADD_EDGE) { this.helpText = 'I18N_INTERACTIONS_GRAPH_EDGE_INITIAL_HELPTEXT'; } else if (this.state.currentMode === this._MODES.MOVE) { this.helpText = 'I18N_INTERACTIONS_GRAPH_MOVE_INITIAL_HELPTEXT'; } else { this.helpText = null; } } else { this.helpText = null; } this.state.addEdgeVertex = null; this.state.selectedVertex = null; this.state.selectedEdge = null; this.state.currentlyDraggedVertex = null; this.state.hoveredVertex = null; } onClickModeButton(mode: number, event: Event): void { event.preventDefault(); event.stopPropagation(); if (this.interactionIsActive) { this.setMode(mode); } } // TODO(#12104): Consider if there's a neat way to write a reset() // function to clear bits of this.state // (e.g. currentlyDraggedVertex, addEdgeVertex). // ---- Vertex events ---- onClickVertex(index: number): void { if (this.state.currentMode === this._MODES.DELETE) { if (this.canDeleteVertex) { this.deleteVertex(index); } } if ( this.state.currentMode !== this._MODES.DELETE && this.graph.isLabeled && this.canEditVertexLabel ) { this.beginEditVertexLabel(index); } this.state.hoveredVertex = index; if ( this.state.addEdgeVertex === null && this.state.currentlyDraggedVertex === null ) { this.onTouchInitialVertex(index); } else { if (this.state.addEdgeVertex === index) { this.state.hoveredVertex = null; if (this.usingMobileDevice) { this.helpText = 'I18N_INTERACTIONS_GRAPH_EDGE_INITIAL_HELPTEXT'; this.state.addEdgeVertex = null; } return; } this.onTouchFinalVertex(index); } } onFocusVertex(index: number): void { this.state.hoveredVertex = index; } onBlurVertex(index: number): void { this.onMouseleaveVertex(index); } onTouchInitialVertex(index: number): void { if (this.state.currentMode === this._MODES.ADD_EDGE) { if (this.canAddEdge) { this.beginAddEdge(index); if (this.usingMobileDevice) { this.helpText = 'I18N_INTERACTIONS_GRAPH_EDGE_FINAL_HELPTEXT'; } } } else if (this.state.currentMode === this._MODES.MOVE) { if (this.canMoveVertex) { this.beginDragVertex(index); if (this.usingMobileDevice) { this.helpText = 'I18N_INTERACTIONS_GRAPH_MOVE_FINAL_HELPTEXT'; } } } } onTouchFinalVertex(index: number): void { if ( this.state.currentMode === this._MODES.ADD_EDGE && this.state.addEdgeVertex !== null ) { this.tryAddEdge(this.state.addEdgeVertex, index); this.endAddEdge(); this.state.hoveredVertex = null; if (this.usingMobileDevice) { this.helpText = 'I18N_INTERACTIONS_GRAPH_EDGE_INITIAL_HELPTEXT'; } } else if (this.state.currentMode === this._MODES.MOVE) { if (this.state.currentlyDraggedVertex !== null) { this.endDragVertex(); this.state.hoveredVertex = null; if (this.usingMobileDevice) { this.helpText = 'I18N_INTERACTIONS_GRAPH_MOVE_INITIAL_HELPTEXT'; } } } } onMousedownVertex(index: number): void { if (this.usingMobileDevice) { return; } if (this.state.currentMode === this._MODES.ADD_EDGE) { if (this.canAddEdge) { this.beginAddEdge(index); } } else if (this.state.currentMode === this._MODES.MOVE) { if (this.canMoveVertex) { this.beginDragVertex(index); } } } onMouseleaveVertex(index: number): void { if (this.usingMobileDevice) { return; } this.state.hoveredVertex = index === this.state.hoveredVertex ? null : this.state.hoveredVertex; } onClickVertexLabel(index: number): void { if (this.graph.isLabeled && this.canEditVertexLabel) { this.beginEditVertexLabel(index); } } // ---- Edge events ---- onClickEdge(index: number): void { if (this.state.currentMode === this._MODES.DELETE) { if (this.canDeleteEdge) { this.deleteEdge(index); } } if ( this.state.currentMode !== this._MODES.DELETE && this.graph.isWeighted && this.canEditEdgeWeight ) { this.beginEditEdgeWeight(index); } } onClickEdgeWeight(index: number): void { if (this.graph.isWeighted && this.canEditEdgeWeight) { this.beginEditEdgeWeight(index); } } // ---- Document event ---- @HostListener('document:mouseup', ['$event']) @debounce() onMouseupDocument(): void { if (this.usingMobileDevice) { return; } if ( this.state.currentMode === this._MODES.ADD_EDGE && this.state.addEdgeVertex !== null ) { if (this.state.hoveredVertex !== null) { this.tryAddEdge(this.state.addEdgeVertex, this.state.hoveredVertex); } this.endAddEdge(); } else if (this.state.currentMode === this._MODES.MOVE) { if (this.state.currentlyDraggedVertex !== null) { this.endDragVertex(); } } } // ---- Actions ---- beginAddEdge(startIndex: number): void { this.state.addEdgeVertex = startIndex; } endAddEdge(): void { this.state.addEdgeVertex = null; } tryAddEdge(startIndex: number, endIndex: number): void { if ( startIndex === null || endIndex === null || startIndex === endIndex || startIndex < 0 || endIndex < 0 || startIndex >= this.graph.vertices.length || endIndex >= this.graph.vertices.length ) { return; } for (let i = 0; i < this.graph.edges.length; i++) { if ( startIndex === this.graph.edges[i].src && endIndex === this.graph.edges[i].dst ) { return; } if (!this.graph.isDirected) { if ( startIndex === this.graph.edges[i].dst && endIndex === this.graph.edges[i].src ) { return; } } } this.graph.edges.push({ src: startIndex, dst: endIndex, weight: 1, }); this.graphChange.emit(this.graph); return; } beginDragVertex(index: number): void { this.state.currentlyDraggedVertex = index; this.state.vertexDragStartX = this.graph.vertices[index].x; this.state.vertexDragStartY = this.graph.vertices[index].y; this.state.mouseDragStartX = this.state.mouseX; this.state.mouseDragStartY = this.state.mouseY; } endDragVertex(): void { this.state.currentlyDraggedVertex = null; this.state.vertexDragStartX = 0; this.state.vertexDragStartY = 0; this.state.mouseDragStartX = 0; this.state.mouseDragStartY = 0; } beginEditVertexLabel(index: number): void { this.state.selectedVertex = index; this.focusManagerService.setFocus('vertexLabelEditBegun'); } beginEditEdgeWeight(index: number): void { this.state.selectedEdge = index; this.selectedEdgeWeightValue = this.graph.edges[this.state.selectedEdge].weight; this.shouldShowWrongWeightWarning = false; this.focusManagerService.setFocus('edgeWeightEditBegun'); } deleteEdge(index: number): void { this.graph.edges.splice(index, 1); this.graphChange.emit(this.graph); this.state.hoveredEdge = null; } private _deleteRepeatedUndirectedEdges(): void { for (let i = 0; i < this.graph.edges.length; i++) { const edge1 = this.graph.edges[i]; for (let j = i + 1; j < this.graph.edges.length; j++) { const edge2 = this.graph.edges[j]; if ( (edge1.src === edge2.src && edge1.dst === edge2.dst) || (edge1.src === edge2.dst && edge1.dst === edge2.src) ) { this.deleteEdge(j); j--; } } } } deleteVertex(index: number): void { this.graph.edges = this.graph.edges .filter(edge => edge.src !== index && edge.dst !== index) .map(edge => { if (edge.src > index) { edge.src--; } if (edge.dst > index) { edge.dst--; } return edge; }); this.graph.vertices.splice(index, 1); this.graphChange.emit(this.graph); this.state.hoveredVertex = null; } get selectedVertexLabel(): string { if (this.state.selectedVertex === null) { return ''; } return this.graph.vertices[this.state.selectedVertex].label; } set selectedVertexLabel(label: string) { if ( this.utilsService.isDefined(label) && this.state.selectedVertex !== null ) { this.graph.vertices[this.state.selectedVertex].label = label; } } get selectedEdgeWeight(): string | number | null { if (this.state.selectedEdge === null) { return ''; } return this.selectedEdgeWeightValue; } set selectedEdgeWeight(weight: number | string | null) { if (weight === null) { this.selectedEdgeWeightValue = ''; } if (isNumber(weight)) { this.selectedEdgeWeightValue = weight; } } isValidEdgeWeight(): boolean { return typeof this.selectedEdgeWeightValue === 'number'; } onUpdateEdgeWeight(): void { if ( this.state.selectedEdge !== null && isNumber(this.selectedEdgeWeightValue) ) { this.graph.edges[this.state.selectedEdge].weight = this.selectedEdgeWeightValue; this.graphChange.emit(this.graph); } this.state.selectedEdge = null; } }