diff --git a/src/app/_models/formly.ts b/src/app/_models/formly.ts index e3f8c4fb8..cd9db6cbb 100644 --- a/src/app/_models/formly.ts +++ b/src/app/_models/formly.ts @@ -47,7 +47,7 @@ export interface FormlyOverviewDisabledSelectConfig warningMessage: string; } -export type InputKind = 'NUMBER' | 'STRING' | 'NAME'; +export type InputKind = 'NUMBER' | 'STRING' | 'NAME' | 'NUMBER_FRACTION'; export interface FormlyInputConfig extends FormlyInterface { placeholder?: string; diff --git a/src/app/_modules/formly_constants.ts b/src/app/_modules/formly_constants.ts index bbc7d3fa2..1531ebd75 100644 --- a/src/app/_modules/formly_constants.ts +++ b/src/app/_modules/formly_constants.ts @@ -16,6 +16,8 @@ import { integerValidator, invalidTimeMessage, notIntegerMessage, + notNumberMesage, + numberValidator, requiredIconMessage, requiredIconValidator, requiredMessage, @@ -53,6 +55,7 @@ export const FORMLY_CONFIG: ConfigOption = { hasSpecialCharactersMessage, fieldsDontMatchMessage, sessionAlreadyHasAuthor, + notNumberMesage, ], validators: [ timeValidator, @@ -63,6 +66,7 @@ export const FORMLY_CONFIG: ConfigOption = { integerValidator, specialCharacterValidator, fieldMatchValidator, + numberValidator, ], }; diff --git a/src/app/_services/formly/formly-service.service.ts b/src/app/_services/formly/formly-service.service.ts index 38786dd7a..ab4b67ddb 100644 --- a/src/app/_services/formly/formly-service.service.ts +++ b/src/app/_services/formly/formly-service.service.ts @@ -134,12 +134,17 @@ export class FormlyService { buildInputConfig(config: FormlyInputConfig): FormlyFieldConfig { const validators = this.getValidators(config); - if (config.inputKind === 'NUMBER') { - validators.push('notInteger'); - } - if (config.inputKind === 'NAME') { - //Why 'hasSpecialCharacters' validation? Names are used in URLs, they mustn't have special characters - validators.push('hasSpecialCharacters'); + switch (config.inputKind) { + case 'NUMBER': + validators.push('notInteger'); + break; + case 'NAME': + //Why 'hasSpecialCharacters' validation? Names are used in URLs, they mustn't have special characters + validators.push('hasSpecialCharacters'); + break; + case 'NUMBER_FRACTION': + validators.push('notNumber'); + break; } let innerInputType: 'string' | 'number'; diff --git a/src/app/_services/formly/validators.ts b/src/app/_services/formly/validators.ts index 59203da12..f5abc4495 100644 --- a/src/app/_services/formly/validators.ts +++ b/src/app/_services/formly/validators.ts @@ -39,6 +39,10 @@ export const notIntegerMessage = { message: 'Your input is not an integer. This field requires an integer number. No amount of revolution can overcome this.', }; +export const notNumberMesage = { + name: 'notNumber', + message: 'Your input is not a number.', +}; export const hasSpecialCharactersMessage = { name: 'hasSpecialCharacters', message: @@ -116,6 +120,18 @@ export const integerValidator: ValidatorOption = { validation: isIntegerValidation, }; +function isNumberValidation(control: AbstractControl): ValidationErrors | null { + const isNumberType = typeof control.value === 'number'; + const isNumberString = + typeof control.value === 'string' && !isNaN(control.value as any); + const isNumber = isNumberType || isNumberString; + return isNumber ? null : { notNumber: !isNumber }; +} +export const numberValidator: ValidatorOption = { + name: 'notNumber', + validation: isNumberValidation, +}; + function hasNoSpecialCharactersValidation( control: AbstractControl, ): ValidationErrors | null { diff --git a/src/app/campaign/components/graph-help-modal/graph-help-modal.component.spec.ts b/src/app/campaign/components/graph-help-modal/graph-help-modal.component.spec.ts deleted file mode 100644 index 76dbae7d8..000000000 --- a/src/app/campaign/components/graph-help-modal/graph-help-modal.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { GraphHelpModalComponent } from './graph-help-modal.component'; - -describe('GraphHelpModalComponent', () => { - let component: GraphHelpModalComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [GraphHelpModalComponent] - }) - .compileComponents(); - - fixture = TestBed.createComponent(GraphHelpModalComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/campaign/components/graph-settings-modal/graph-settings-modal.component.html b/src/app/campaign/components/graph-settings-modal/graph-settings-modal.component.html new file mode 100644 index 000000000..b7ef0ed8a --- /dev/null +++ b/src/app/campaign/components/graph-settings-modal/graph-settings-modal.component.html @@ -0,0 +1,32 @@ + + + + + + + diff --git a/src/app/campaign/components/graph-settings-modal/graph-settings-modal.component.scss b/src/app/campaign/components/graph-settings-modal/graph-settings-modal.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/campaign/components/graph-settings-modal/graph-settings-modal.component.ts b/src/app/campaign/components/graph-settings-modal/graph-settings-modal.component.ts new file mode 100644 index 000000000..70c913b37 --- /dev/null +++ b/src/app/campaign/components/graph-settings-modal/graph-settings-modal.component.ts @@ -0,0 +1,77 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + output, + TemplateRef, +} from '@angular/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { FormlyFieldConfig } from '@ngx-formly/core'; +import { FormlyService } from 'src/app/_services/formly/formly-service.service'; +import { ButtonComponent } from 'src/design/atoms/button/button.component'; +import { GRAPH_SETTINGS } from 'src/design/organisms/_model/graph'; +import { IconComponent } from '../../../../design/atoms/icon/icon.component'; +import { FormComponent } from '../../../../design/molecules/form/form.component'; + +@Component({ + selector: 'app-graph-settings-modal', + standalone: true, + imports: [ButtonComponent, FormComponent, IconComponent], + templateUrl: './graph-settings-modal.component.html', + styleUrl: './graph-settings-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class GraphSettingsModalComponent { + modalService = inject(NgbModal); + formlyService = inject(FormlyService); + + settings = input(GRAPH_SETTINGS); + + newSettings = output(); + + formlyFields = computed(() => [ + this.formlyService.buildInputConfig({ + key: 'linkAttractingForce', + inputKind: 'NUMBER_FRACTION', + label: 'Strength of links pulling nodes together', + }), + this.formlyService.buildInputConfig({ + key: 'nodeRepellingForce', + inputKind: 'NUMBER_FRACTION', + label: 'Strength of nodes repelling each other', + }), + this.formlyService.buildInputConfig({ + key: 'xForce', + inputKind: 'NUMBER_FRACTION', + label: 'Horizontal force', + }), + this.formlyService.buildInputConfig({ + key: 'yForce', + inputKind: 'NUMBER_FRACTION', + label: 'Vertical force', + }), + ]); + + onSettingsSubmit(model: any) { + const newSettings: typeof GRAPH_SETTINGS = { + ...model, + xForce: parseFloat(model.xForce), + yForce: parseFloat(model.yForce), + linkAttractingForce: parseFloat(model.linkAttractingForce), + nodeRepellingForce: parseFloat(model.nodeRepellingForce), + }; + + this.newSettings.emit(newSettings); + + this.modalService.dismissAll(); + } + + openModal(content: TemplateRef) { + this.modalService.open(content, { + ariaLabelledBy: 'modal-title', + modalDialogClass: 'border border-info border-3 rounded mymodal', + }); + } +} diff --git a/src/app/campaign/pages/graph-page/graph-page.component.html b/src/app/campaign/pages/graph-page/graph-page.component.html index e85a04d37..e29c26024 100644 --- a/src/app/campaign/pages/graph-page/graph-page.component.html +++ b/src/app/campaign/pages/graph-page/graph-page.component.html @@ -28,9 +28,13 @@

Wiki Overview

-
+
+
diff --git a/src/app/campaign/pages/graph-page/graph-page.component.ts b/src/app/campaign/pages/graph-page/graph-page.component.ts index 41c476600..7e070aaa0 100644 --- a/src/app/campaign/pages/graph-page/graph-page.component.ts +++ b/src/app/campaign/pages/graph-page/graph-page.component.ts @@ -36,6 +36,7 @@ import { CategoryLabel, GRAPH_CATEGORIES, } from 'src/design/molecules/_models/search-preferences'; +import { GRAPH_SETTINGS } from 'src/design/organisms/_model/graph'; import { GraphMenuService } from 'src/design/organisms/graph/graph-menu.service'; import { GraphService } from 'src/design/organisms/graph/graph.service'; import { filterNil } from 'src/utils/rxjs-operators'; @@ -50,6 +51,7 @@ import { SearchFieldComponent } from '../../../../design/molecules/search-field/ import { GraphComponent } from '../../../../design/organisms/graph/graph.component'; import { PageContainerComponent } from '../../../../design/organisms/page-container/page-container.component'; import { GraphHelpModalComponent } from '../../components/graph-help-modal/graph-help-modal.component'; +import { GraphSettingsModalComponent } from '../../components/graph-settings-modal/graph-settings-modal.component'; import { GraphPageStore } from './graph-page.store'; @Component({ @@ -69,6 +71,7 @@ import { GraphPageStore } from './graph-page.store'; ConfirmationToggleButtonComponent, SearchFieldComponent, GraphHelpModalComponent, + GraphSettingsModalComponent, ], templateUrl: './graph-page.component.html', styleUrl: './graph-page.component.scss', @@ -102,6 +105,7 @@ export class GraphPageComponent { ), shareReplay(1), ); + graphSettings = signal(GRAPH_SETTINGS); pageState = signal<'DISPLAY' | 'CREATE'>('DISPLAY'); isPanelOpen = signal(false); @@ -202,6 +206,11 @@ export class GraphPageComponent { this.store.deleteConnection(linkId); } + onSettingsChange(newSettings: typeof GRAPH_SETTINGS) { + console.log('New settings: ', newSettings); + this.graphSettings.set(newSettings); + } + private filterGraphData( graphData: NodeMap | undefined, filterCategories: Set, diff --git a/src/design/atoms/_models/icon.ts b/src/design/atoms/_models/icon.ts index a1374b1b4..a513c5d81 100644 --- a/src/design/atoms/_models/icon.ts +++ b/src/design/atoms/_models/icon.ts @@ -52,6 +52,7 @@ export const ALL_SOLID_ICONS = [ 'file-audio', 'file-import', 'file', + 'gear', 'gavel', 'globe-americas', 'hammer', diff --git a/src/design/organisms/graph/data.ts b/src/design/organisms/_model/graph.ts similarity index 76% rename from src/design/organisms/graph/data.ts rename to src/design/organisms/_model/graph.ts index bb10d2be9..b3cd74d47 100644 --- a/src/design/organisms/graph/data.ts +++ b/src/design/organisms/_model/graph.ts @@ -24,6 +24,22 @@ export const SELECTORS = { deleteLinkSelector: '#delete-link', }; +export const GRAPH_SETTINGS = { + width: 1080, + minZoom: 0.5, + maxZoom: 12, + minHeight: 300, + linkAttractingForce: 0.5, + nodeRepellingForce: 50, + undirectedForce: 0.025, + circleSize: 6, + xForce: 1, + yForce: 1, + centeringTransitionTime: 1000, + hoverTransitionTime: 200, + strokeWidth: 0.5, +}; + export type NodeClickEvent = { event: MouseEvent; clickedNode: ArticleNode | undefined; diff --git a/src/design/organisms/graph/graph-menu.service.ts b/src/design/organisms/graph/graph-menu.service.ts index 7343cc576..2e239f562 100644 --- a/src/design/organisms/graph/graph-menu.service.ts +++ b/src/design/organisms/graph/graph-menu.service.ts @@ -9,8 +9,8 @@ import { } from 'src/app/_models/nodeMap'; import { ArticleService } from 'src/app/_services/article/article.service'; import { ellipsize } from 'src/utils/string'; +import { LinkClickEvent, SELECTORS } from '../_model/graph'; import { SIDEBAR_ENTRIES } from '../_model/sidebar'; -import { LinkClickEvent, SELECTORS } from './data'; export type NodeMenuData = { title: string | undefined; diff --git a/src/design/organisms/graph/graph.component.html b/src/design/organisms/graph/graph.component.html index dfc5c5e25..a6019ff17 100644 --- a/src/design/organisms/graph/graph.component.html +++ b/src/design/organisms/graph/graph.component.html @@ -14,9 +14,9 @@ #zoomControl type="range" aria-label="'Zoom level of graph'" - [min]="settings.minZoom" - [max]="settings.maxZoom" - [value]="(zoomLevel$ | async) ?? settings.minZoom" + [min]="graphSettings().minZoom" + [max]="graphSettings().maxZoom" + [value]="(zoomLevel$ | async) ?? graphSettings().minZoom" [step]="0.1" (input)="zoomSliderEvents$.next(zoomControl.value)" class="controls__slider" diff --git a/src/design/organisms/graph/graph.component.ts b/src/design/organisms/graph/graph.component.ts index 68418538e..454e389e0 100644 --- a/src/design/organisms/graph/graph.component.ts +++ b/src/design/organisms/graph/graph.component.ts @@ -15,23 +15,9 @@ import { filter, map, Subject, take } from 'rxjs'; import { NodeMap, NodeSelection } from 'src/app/_models/nodeMap'; import { ArticleService } from 'src/app/_services/article/article.service'; import { ButtonComponent } from 'src/design/atoms/button/button.component'; +import { GRAPH_SETTINGS } from '../_model/graph'; import { GraphService } from './graph.service'; -const GRAPH_SETTINGS = { - width: 1080, - minZoom: 0.5, - maxZoom: 12, - minHeight: 300, - linkAttractingForce: 0.5, - nodeRepellingForce: 50, - circleSize: 6, - xForce: 1, - yForce: 1, - centeringTransitionTime: 1000, - hoverTransitionTime: 200, - strokeWidth: 0.5, -}; - @Component({ selector: 'app-graph', standalone: true, @@ -43,6 +29,7 @@ const GRAPH_SETTINGS = { export class GraphComponent { data = input.required(); activeNodesData = input.required(); + graphSettings = input.required(); articleService = inject(ArticleService); graphService = inject(GraphService); //Accessible as the parent, GraphPageComponent, provides an instance @@ -52,14 +39,19 @@ export class GraphComponent { elements = toSignal(this.graphService.elements$); zoomLevel$ = this.graphService.zoomLevelChangedEvent$; - settings = GRAPH_SETTINGS; - zoomSliderEvents$ = new Subject(); constructor() { - effect(() => this.graphService.createGraphEvents$.next(this.data()), { - allowSignalWrites: true, - }); + effect( + () => + this.graphService.createGraphEvents$.next({ + data: this.data(), + settings: this.graphSettings(), + }), + { + allowSignalWrites: true, + }, + ); // Replace graph in HTML if graph changes effect(() => { diff --git a/src/design/organisms/graph/graph.service.ts b/src/design/organisms/graph/graph.service.ts index 73cb4339c..1b8c57d5a 100644 --- a/src/design/organisms/graph/graph.service.ts +++ b/src/design/organisms/graph/graph.service.ts @@ -38,25 +38,14 @@ import { import { log } from 'src/utils/logging'; import { filterNil } from 'src/utils/rxjs-operators'; import { capitalize } from 'src/utils/string'; -import { LinkClickEvent, NodeClickEvent, SELECTORS } from './data'; +import { + GRAPH_SETTINGS, + LinkClickEvent, + NodeClickEvent, + SELECTORS, +} from '../_model/graph'; import { GraphElement, GraphMenuService } from './graph-menu.service'; -export const GRAPH_SETTINGS = { - width: 1080, - minZoom: 0.5, - maxZoom: 12, - minHeight: 300, - linkAttractingForce: 0.5, - nodeRepellingForce: 50, - undirectedForce: 0.025, - circleSize: 6, - xForce: 1, - yForce: 1, - centeringTransitionTime: 1000, - hoverTransitionTime: 200, - strokeWidth: 0.5, -}; - type MyZoomBehavior = ZoomBehavior; type ZoomElement = Selection; type GraphElements = { @@ -67,18 +56,21 @@ type GraphElements = { @Injectable() export class GraphService { - public createGraphEvents$ = new ReplaySubject(1); + public createGraphEvents$ = new ReplaySubject<{ + data: NodeMap; + settings: typeof GRAPH_SETTINGS; + }>(1); private _elements$ = new Subject(); public elements$ = this._elements$.asObservable(); private _nodeClickEvents$ = new Subject(); public nodeClickEvents$ = this._nodeClickEvents$.pipe( withLatestFrom(this.createGraphEvents$), - map(([event, graphData]) => this.toNodeEvent(event, graphData)), + map(([event, graphData]) => this.toNodeEvent(event, graphData.data)), ); private _nodeRightClickEvents$ = new Subject(); public nodeRightClickEvents$ = this._nodeRightClickEvents$.pipe( withLatestFrom(this.createGraphEvents$), - map(([event, graphData]) => this.toNodeEvent(event, graphData)), + map(([event, graphData]) => this.toNodeEvent(event, graphData.data)), ); private linkRightClickEvents$ = new Subject(); public centerNodeEvents$ = new Subject(); @@ -119,7 +111,7 @@ export class GraphService { tap(() => this._elements$.next(undefined)), takeUntilDestroyed(), ) - .subscribe((data) => this.createGraph(data)); + .subscribe((data) => this.createGraph(data.data, data.settings)); } /** @@ -195,10 +187,6 @@ export class GraphService { event.event, event.clickedLink, ); - // TODO: - // 1. Figure out how to extract the clicked links from via event from links. - // 2. Transform into the data needed to open a context menu - // 3. render a context menu with its own html etc. Make sure that it has a delete option that is disabled if the link is not a custom link }); } @@ -216,10 +204,11 @@ export class GraphService { filterNil(), ), ), + withLatestFrom(this.createGraphEvents$), takeUntilDestroyed(), ) - .subscribe(([centeredNodeData, zoomBehavior]) => - this.centerNodeInGraph(centeredNodeData, zoomBehavior), + .subscribe(([[centeredNodeData, zoomBehavior], { settings }]) => + this.centerNodeInGraph(centeredNodeData, zoomBehavior, settings), ); } @@ -233,10 +222,10 @@ export class GraphService { } // HELPER FUNCTIONS - private createGraph(nodeMap: NodeMap) { - const height = this.inferGraphHeight(GRAPH_SETTINGS.width, getBreakpoint()); + private createGraph(nodeMap: NodeMap, settings: typeof GRAPH_SETTINGS) { + const height = this.inferGraphHeight(settings.width, getBreakpoint()); const graphElement = create('svg') - .attr('viewBox', [0, 0, GRAPH_SETTINGS.width, height]) + .attr('viewBox', [0, 0, settings.width, height]) .attr( 'style', 'max-width: 100%; height: auto; min-height: 300px; cursor: move;', @@ -246,8 +235,9 @@ export class GraphService { const zoomBehavior = this.addZoomListener( graphElement, zoomContainer, - GRAPH_SETTINGS.width, + settings.width, height, + settings, ); graphElement.on('click', (event: MouseEvent) => @@ -262,7 +252,11 @@ export class GraphService { // Add a line for each link, and a circle for each node. this.addLinks(zoomContainer, nodeMap.links); - const allNodesElement = this.addNodes(zoomContainer, nodeMap.nodes); + const allNodesElement = this.addNodes( + zoomContainer, + nodeMap.nodes, + settings, + ); // Create a simulation with several forces. const simulation = forceSimulation(nodeMap.nodes) @@ -270,15 +264,15 @@ export class GraphService { 'link', forceLink(nodeMap.links) .id((d) => d.record.name) - .strength(GRAPH_SETTINGS.linkAttractingForce), + .strength(settings.linkAttractingForce), ) .force( 'charge', - forceManyBody().strength(-1 * GRAPH_SETTINGS.nodeRepellingForce), + forceManyBody().strength(-1 * settings.nodeRepellingForce), ) - .force('x', forceX().strength(GRAPH_SETTINGS.undirectedForce)) - .force('y', forceY().strength(GRAPH_SETTINGS.undirectedForce)) - .force('center', forceCenter(GRAPH_SETTINGS.width / 2, height / 2)); + .force('x', forceX().strength(settings.undirectedForce)) + .force('y', forceY().strength(settings.undirectedForce)) + .force('center', forceCenter(settings.width / 2, height / 2)); addDragBehavior(allNodesElement, simulation); // Set the position attributes of links and nodes each time the simulation ticks. @@ -293,6 +287,7 @@ export class GraphService { undefined >, nodeData: ArticleNode[], + settings: typeof GRAPH_SETTINGS, ) { const nodes = hostElement .append('g') @@ -315,7 +310,7 @@ export class GraphService { // imgGroups nodes .append('circle') - .attr('r', GRAPH_SETTINGS.circleSize) + .attr('r', settings.circleSize) .attr('stroke', 'black') .attr('guid', (d) => d.guid) .attr( @@ -329,10 +324,10 @@ export class GraphService { // Add Label nodes .append('text') - .attr('y', GRAPH_SETTINGS.circleSize * 6) + .attr('y', settings.circleSize * 6) .attr('text-anchor', 'middle') .attr('stroke', '#000') - .attr('stroke-width', GRAPH_SETTINGS.strokeWidth) + .attr('stroke-width', settings.strokeWidth) .attr('transform', 'scale(0.3)') .attr('guid', (d) => d.guid) .text((d) => d.record.name); @@ -455,12 +450,16 @@ export class GraphService { ); } - private centerNodeInGraph(node: ArticleNode, myZoom: MyZoomBehavior) { + private centerNodeInGraph( + node: ArticleNode, + myZoom: MyZoomBehavior, + settings: typeof GRAPH_SETTINGS, + ) { const graph = select(SELECTORS.graphSelector); if (graph.empty()) return; myZoom.translateTo( - graph.transition().duration(GRAPH_SETTINGS.centeringTransitionTime), + graph.transition().duration(settings.centeringTransitionTime), node.x as number, node.y as number, ); @@ -509,13 +508,14 @@ export class GraphService { zoomContainer: ZoomElement, width: number, height: number, + settings: typeof GRAPH_SETTINGS, ) { const zoomBehavior = zoom() .extent([ [0, 0], [width, height], ]) - .scaleExtent([GRAPH_SETTINGS.minZoom, GRAPH_SETTINGS.maxZoom]) + .scaleExtent([settings.minZoom, settings.maxZoom]) .on('zoom', (val) => { zoomContainer.attr('transform', val.transform); this.zoomLevelChangedEvent$.next(val.transform.k);