UNPKG

graph-explorer

Version:

Graph Explorer can be used to explore and RDF graphs in SPARQL endpoints or on the web.

448 lines (388 loc) 11.6 kB
import { ReactNode } from "react"; import { hcl } from "d3-color"; import { defaultsDeep, cloneDeep } from "lodash"; import { ReactElement, MouseEvent } from "react"; import { LinkRouter, TypeStyleResolver, LinkTemplateResolver, TemplateResolver, ElementTemplate, LinkTemplate, RoutedLink, RoutedLinks, } from "../customization/props"; import { DefaultTypeStyleBundle } from "../customization/defaultTypeStyles"; import { DefaultLinkTemplateBundle } from "../customization/defaultLinkStyles"; import { StandardTemplate, DefaultElementTemplateBundle, } from "../customization/templates"; import { ElementModel, LocalizedString, ElementTypeIri, LinkTypeIri, } from "../data/model"; import { isEncodedBlank } from "../data/sparql/blankNodes"; import { hashFnv32a, getUriLocalName } from "../data/utils"; import { Events, EventSource, EventObserver, PropertyChange, } from "../viewUtils/events"; import { Element, Link, FatLinkType } from "./elements"; import { Vector, isPolylineEqual } from "./geometry"; import { DefaultLinkRouter } from "./linkRouter"; import { DiagramModel } from "./model"; export enum IriClickIntent { JumpToEntity = "jumpToEntity", OpenEntityIri = "openEntityIri", OpenOtherIri = "openOtherIri", } export interface IriClickEvent { iri: string; element: Element; clickIntent: IriClickIntent; originalEvent: MouseEvent<any>; } export type IriClickHandler = (event: IriClickEvent) => void; export type LabelLanguageSelector = ( labels: readonly LocalizedString[], language: string ) => LocalizedString | undefined; export interface ViewOptions { typeStyleResolver?: TypeStyleResolver; linkTemplateResolver?: LinkTemplateResolver; elementTemplateResolver?: TemplateResolver; selectLabelLanguage?: LabelLanguageSelector; linkRouter?: LinkRouter; onIriClick?: IriClickHandler; } export interface TypeStyle { color: { h: number; c: number; l: number }; icon?: string; } export enum RenderingLayer { Element = 1, ElementSize, PaperArea, Link, Editor, FirstToUpdate = Element, LastToUpdate = Editor, } export interface DiagramViewEvents { changeLanguage: PropertyChange<DiagramView, string>; changeLinkTemplates: {}; syncUpdate: { layer: RenderingLayer }; updateWidgets: UpdateWidgetsEvent; dispose: {}; changeHighlight: PropertyChange<DiagramView, Highlighter>; updateRoutings: PropertyChange<DiagramView, RoutedLinks>; } export interface UpdateWidgetsEvent { widgets: Record<string, WidgetDescription>; } export enum WidgetAttachment { Viewport = 1, OverElements, OverLinks, } export interface WidgetDescription { element: ReactElement<any>; attachment: WidgetAttachment; } export interface DropOnPaperEvent { dragEvent: DragEvent; paperPosition: Vector; } export type Highlighter = ((item: Element | Link) => boolean) | undefined; export type ElementDecoratorResolver = ( element: Element ) => ReactNode | undefined; export class DiagramView { private readonly listener = new EventObserver(); private readonly source = new EventSource<DiagramViewEvents>(); readonly events: Events<DiagramViewEvents> = this.source; private disposed = false; private readonly colorSeed = 0x0badbeef; private readonly resolveTypeStyle: TypeStyleResolver; private readonly resolveLinkTemplate: LinkTemplateResolver; private readonly resolveElementTemplate: TemplateResolver; private _language = "en"; private linkTemplates = new Map<LinkTypeIri, LinkTemplate>(); private router: LinkRouter; private routings: RoutedLinks = new Map<string, RoutedLink>(); private dropOnPaperHandler: ((e: DropOnPaperEvent) => void) | undefined; private _highlighter: Highlighter; private _elementDecorator: ElementDecoratorResolver; constructor( public readonly model: DiagramModel, public readonly options: ViewOptions = {} ) { this.resolveTypeStyle = options.typeStyleResolver || DefaultTypeStyleBundle; this.resolveLinkTemplate = options.linkTemplateResolver || DefaultLinkTemplateBundle; this.resolveElementTemplate = options.elementTemplateResolver || DefaultElementTemplateBundle; this.initRouting(); } private initRouting() { this.router = this.options.linkRouter || new DefaultLinkRouter(); this.updateRoutings(); this.listener.listen(this.model.events, "changeCells", () => this.updateRoutings() ); this.listener.listen( this.model.events, "linkEvent", ({ key: _key, data }) => { if (data.changeVertices) { this.updateRoutings(); } } ); this.listener.listen( this.model.events, "elementEvent", ({ key: _key, data }) => { if (data.changePosition || data.changeSize) { this.updateRoutings(); } } ); } private updateRoutings() { const previousRoutes = this.routings; const computedRoutes = this.router.route(this.model); previousRoutes.forEach((previous, linkId) => { const computed = computedRoutes.get(linkId); if (computed && sameRoutedLink(previous, computed)) { // replace new route with the old one if they're equal // so other components can use a simple reference equality checks computedRoutes.set(linkId, previous); } }); this.routings = computedRoutes; this.source.trigger("updateRoutings", { source: this, previous: previousRoutes, }); } getRoutings() { return this.routings; } getRouting(linkId: string): RoutedLink { return this.routings.get(linkId); } getLanguage(): string { return this._language; } setLanguage(value: string) { if (!value) { throw new Error("Cannot set empty language."); } const previous = this._language; if (previous === value) { return; } this._language = value; this.source.trigger("changeLanguage", { source: this, previous }); } getLinkTemplates(): ReadonlyMap<LinkTypeIri, LinkTemplate> { return this.linkTemplates; } performSyncUpdate() { for ( let layer = RenderingLayer.FirstToUpdate; layer <= RenderingLayer.LastToUpdate; layer++ ) { this.source.trigger("syncUpdate", { layer }); } } onIriClick( iri: string, element: Element, clickIntent: IriClickIntent, event: React.MouseEvent<any> ) { event.persist(); event.preventDefault(); const { onIriClick } = this.options; if (onIriClick) { onIriClick({ iri, element, clickIntent, originalEvent: event }); } } setPaperWidget(widget: { key: string; widget: ReactElement<any> | undefined; attachment: WidgetAttachment; }) { const { key, widget: element, attachment } = widget; const widgets = { [key]: element ? { element, attachment } : undefined }; this.source.trigger("updateWidgets", { widgets }); } setHandlerForNextDropOnPaper(handler: (e: DropOnPaperEvent) => void) { this.dropOnPaperHandler = handler; } _tryHandleDropOnPaper(e: DropOnPaperEvent): boolean { const { dropOnPaperHandler } = this; if (dropOnPaperHandler) { this.dropOnPaperHandler = undefined; dropOnPaperHandler(e); return true; } return false; } selectLabel( labels: readonly LocalizedString[], language?: string ): LocalizedString | undefined { const targetLanguage = typeof language === "undefined" ? this.getLanguage() : language; const { selectLabelLanguage = defaultSelectLabel } = this.options; return selectLabelLanguage(labels, targetLanguage); } formatLabel( labels: readonly LocalizedString[], fallbackIri: string, language?: string ): string { const label = this.selectLabel(labels, language); return resolveLabel(label, fallbackIri); } public getElementTypeString(elementModel: ElementModel): string { return elementModel.types .map((typeId) => { const type = this.model.createClass(typeId); return this.formatLabel(type.label, type.id); }) .sort() .join(", "); } public getTypeStyle(types: ElementTypeIri[]): TypeStyle { types.sort(); const customStyle = this.resolveTypeStyle(types); const icon = customStyle ? customStyle.icon : undefined; let color: { h: number; c: number; l: number }; if (customStyle && customStyle.color) { color = hcl(customStyle.color); } else { const hue = getHueFromClasses(types, this.colorSeed); color = { h: hue, c: 40, l: 75 }; } return { icon, color }; } formatIri(iri: string): string { if (isEncodedBlank(iri)) { return "(blank node)"; } return `<${iri}>`; } public getElementTemplate(types: ElementTypeIri[]): ElementTemplate { return this.resolveElementTemplate(types) || StandardTemplate; } createLinkTemplate(linkType: FatLinkType): LinkTemplate { const existingTemplate = this.linkTemplates.get(linkType.id); if (existingTemplate) { return existingTemplate; } let template: LinkTemplate = {}; const result = this.resolveLinkTemplate(linkType.id); if (result) { template = cloneDeep(result); } fillLinkTemplateDefaults(template); this.linkTemplates.set(linkType.id, template); this.source.trigger("changeLinkTemplates", {}); return template; } dispose() { if (this.disposed) { return; } this.source.trigger("dispose", {}); this.listener.stopListening(); this.disposed = true; } get highlighter() { return this._highlighter; } setHighlighter(value: Highlighter) { const previous = this._highlighter; if (previous === value) { return; } this._highlighter = value; this.source.trigger("changeHighlight", { source: this, previous }); } _setElementDecorator(decorator: ElementDecoratorResolver) { this._elementDecorator = decorator; } _decorateElement(element: Element): ReactNode | undefined { return this._elementDecorator(element); } } function sameRoutedLink(a: RoutedLink, b: RoutedLink) { return ( a.linkId === b.linkId && a.labelTextAnchor === b.labelTextAnchor && isPolylineEqual(a.vertices, b.vertices) ); } function getHueFromClasses( classes: readonly ElementTypeIri[], seed?: number ): number { let hash = seed; for (const name of classes) { hash = hashFnv32a(name, hash); } const MAX_INT32 = 0x7fffffff; return 360 * ((hash === undefined ? 0 : hash) / MAX_INT32); } function fillLinkTemplateDefaults(template: LinkTemplate) { const defaults: Partial<LinkTemplate> = { markerTarget: { d: "M0,0 L0,8 L9,4 z", width: 9, height: 8, fill: "black" }, }; defaultsDeep(template, defaults); if (!template.renderLink) { template.renderLink = () => ({}); } } function defaultSelectLabel( texts: readonly LocalizedString[], language: string ): LocalizedString | undefined { if (texts.length === 0) { return undefined; } let defaultValue: LocalizedString; let englishValue: LocalizedString; for (const text of texts) { if (text.language === language) { return text; } else if (text.language === "") { defaultValue = text; } else if (text.language === "en") { englishValue = text; } } return defaultValue !== undefined ? defaultValue : englishValue !== undefined ? englishValue : texts[0]; } function resolveLabel( label: LocalizedString | undefined, fallbackIri: string ): string { if (label) { return label.value; } return getUriLocalName(fallbackIri) || fallbackIri; }