├── src ├── vite-env.d.ts ├── index.umd.ts ├── modules │ ├── vector2d │ │ ├── core.ts │ │ ├── index.ts │ │ ├── vector2d.ts │ │ └── methods.ts │ ├── vue │ │ └── nextTick.ts │ ├── collection │ │ ├── iterate.ts │ │ └── array.ts │ ├── node │ │ ├── node.ts │ │ └── label.ts │ ├── calculation │ │ └── line.ts │ └── svg-pan-zoom-ex.ts ├── utils │ ├── map.ts │ ├── collection.ts │ ├── string.ts │ ├── download.ts │ ├── object.ts │ ├── box.ts │ ├── visual.ts │ ├── props.ts │ └── svg.ts ├── components │ ├── base │ │ ├── VStyle.vue │ │ ├── VSelectionBox.vue │ │ ├── VLine.vue │ │ ├── VArc.vue │ │ ├── VShape.vue │ │ └── VLabelText.vue │ ├── layers │ │ ├── VPathsLayer.vue │ │ ├── VFocusringLayer.vue │ │ ├── VEdgesLayer.vue │ │ ├── VEdgeLabelsLayer.vue │ │ ├── VNodesLayer.vue │ │ └── VNodeLabelsLayer.vue │ ├── index.ts │ ├── marker │ │ ├── VMarkerHeadCircle.vue │ │ ├── VMarkerHeadArrow.vue │ │ ├── VMarkerHeadAngle.vue │ │ └── VMarkerHead.vue │ ├── edge │ │ ├── VEdgeBackgrounds.vue │ │ ├── VEdgeLabelPlace.vue │ │ ├── VEdgeLabelsPlace.vue │ │ ├── VEdge.vue │ │ ├── VEdgeCurved.vue │ │ ├── VEdgeBackground.vue │ │ ├── VEdgeGroups.vue │ │ ├── VEdgeLabels.vue │ │ ├── VEdgeSummarized.vue │ │ ├── VEdgeOverlay.vue │ │ └── VEdgeLabel.vue │ ├── svg │ │ ├── VSvgCircle.vue │ │ ├── VSvgRect.vue │ │ ├── VSvgPath.vue │ │ └── VSvgLine.vue │ ├── path │ │ ├── VPaths.vue │ │ └── VPath.vue │ ├── background │ │ ├── VBackgroundViewport.vue │ │ └── VBackgroundGrid.vue │ └── node │ │ ├── VNodeFocusRing.vue │ │ └── VNode.vue ├── force-layout.ts ├── composables │ ├── id.ts │ ├── layout.ts │ ├── event-emitter.ts │ ├── zoom.ts │ ├── selection.ts │ ├── container.ts │ ├── transition.ts │ ├── config.ts │ ├── object.ts │ ├── layer.ts │ ├── svg-pan-zoom.ts │ ├── marker.ts │ ├── mouse │ │ ├── core.ts │ │ ├── container.ts │ │ └── index.ts │ └── objectState.ts ├── shims-vue.d.ts ├── force-layout.umd.ts ├── layouts │ ├── grid.ts │ ├── handler.ts │ └── simple.ts ├── models │ ├── node.ts │ ├── path.ts │ └── edge.ts ├── common │ ├── common.ts │ └── types.ts └── index.ts ├── tests ├── tsconfig.json ├── modules │ ├── collection │ │ ├── iterate.spec.ts │ │ └── array.spec.ts │ ├── node │ │ └── node.spec.ts │ ├── calculation │ │ ├── curve.spec.ts │ │ └── 2d.spec.ts │ └── vector │ │ └── methods.spec.ts ├── utils │ └── box.test.ts └── composables │ └── layer.spec.ts ├── .github └── FUNDING.yml ├── tsconfig.test.json ├── .gitignore ├── .npmignore ├── .vscode ├── extensions.json └── settings.json ├── .prettierrc ├── tsconfig.json ├── LICENSE ├── BACKERS.md ├── vite-umd-force.config.ts ├── vite-umd-main.config.ts ├── eslint.config.js ├── package.json └── vite.config.ts /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.test.json" 3 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: dash14 2 | custom: https://www.buymeacoffee.com/dash14.ack 3 | -------------------------------------------------------------------------------- /src/index.umd.ts: -------------------------------------------------------------------------------- 1 | export { default as install } from "./index" 2 | export * from "./index" 3 | -------------------------------------------------------------------------------- /src/modules/vector2d/core.ts: -------------------------------------------------------------------------------- 1 | export interface Point2D { 2 | x: number 3 | y: number 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["tests/**/*.ts"], 4 | "exclude": [] 5 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | /lib 4 | /umd 5 | dist-ssr 6 | *.local 7 | /docs/.vitepress/dist 8 | /stats-*.html 9 | -------------------------------------------------------------------------------- /src/utils/map.ts: -------------------------------------------------------------------------------- 1 | 2 | export class MapUtil { 3 | static valueOf(map: Map) { 4 | return Array.from(map.values()) 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist-ssr 4 | *.local 5 | 6 | .vscode 7 | src/ 8 | vite.config.ts 9 | vite-lib.config.ts 10 | tsconfig.json 11 | -------------------------------------------------------------------------------- /src/components/base/VStyle.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /src/force-layout.ts: -------------------------------------------------------------------------------- 1 | export { ForceLayout } from "./layouts/force" 2 | export type { ForceNodeDatum, ForceEdgeDatum, ForceLayoutParameters } from "./layouts/force" 3 | -------------------------------------------------------------------------------- /src/composables/id.ts: -------------------------------------------------------------------------------- 1 | let nextId = 1 2 | 3 | /** Generate unique ID in v-network-graph instances */ 4 | export function useId(): number { 5 | return nextId++ 6 | } 7 | -------------------------------------------------------------------------------- /src/components/layers/VPathsLayer.vue: -------------------------------------------------------------------------------- 1 | 4 | 7 | -------------------------------------------------------------------------------- /src/modules/vue/nextTick.ts: -------------------------------------------------------------------------------- 1 | import { nextTick } from "vue" 2 | 3 | export const asyncNextTick = () => { 4 | return new Promise((resolve) => nextTick(resolve as () => void)) 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/collection.ts: -------------------------------------------------------------------------------- 1 | 2 | type Args = [...(T | null)[], T] 3 | 4 | export function findFirstNonNull(...values: Args): T { 5 | return values.find(v => !!v) as T 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "vue.volar", 4 | "dbaeumer.vscode-eslint", 5 | "esbenp.prettier-vscode", 6 | "streetsidesoftware.code-spell-checker" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/collection/iterate.ts: -------------------------------------------------------------------------------- 1 | 2 | export function pairwise(arr: T[], func: (p1: T, p2: T) => void) { 3 | for (let i = 0; i < arr.length - 1; i++) { 4 | func(arr[i], arr[i + 1]) 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.vue" { 2 | import type { DefineComponent } from "vue" 3 | const component: DefineComponent, Record, unknown> 4 | export default component 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/vector2d/index.ts: -------------------------------------------------------------------------------- 1 | export { Vector2D } from "./vector2d" 2 | export * from "./methods" 3 | 4 | import { Vector2D } from "./vector2d" 5 | import * as V from "./methods" 6 | 7 | export default { 8 | Vector2D, 9 | ...V 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/string.ts: -------------------------------------------------------------------------------- 1 | 2 | export function convertToAscii(source: string): string { 3 | if (typeof btoa === undefined) { 4 | return Buffer.from(source).toString("base64").replaceAll("=", "") 5 | } else { 6 | return btoa(source).replaceAll("=", "") 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/force-layout.umd.ts: -------------------------------------------------------------------------------- 1 | import { ForceLayout } from "./layouts/force" 2 | 3 | if ((globalThis as any)["VNetworkGraph"]) { 4 | Object.assign((globalThis as any)["VNetworkGraph"], { 5 | ForceLayout, 6 | }) 7 | } 8 | 9 | export default { 10 | ForceLayout, 11 | } 12 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | 2 | export { default as VNetworkGraph } from "./VNetworkGraph.vue" 3 | 4 | export { default as VShape } from "./base/VShape.vue" 5 | 6 | export { default as VStyle } from "./base/VStyle.vue" 7 | 8 | export { default as VEdgeLabel } from "./edge/VEdgeLabel.vue" 9 | 10 | export { default as VLabelText } from "./base/VLabelText.vue" 11 | -------------------------------------------------------------------------------- /src/modules/collection/array.ts: -------------------------------------------------------------------------------- 1 | 2 | export function removeItem(arr: T[], value: T): void { 3 | const i = arr.indexOf(value) 4 | if (i >= 0) arr.splice(i, 1) 5 | } 6 | 7 | export function insertAfter(arr: T[], base: T, value: T): void { 8 | const i = arr.indexOf(base) 9 | if (i < 0) return 10 | arr.splice(i + 1, 0, value) 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/download.ts: -------------------------------------------------------------------------------- 1 | 2 | export async function urlContentToDataUrl(url: string) { 3 | const response = await fetch(url) 4 | const blob = await response.blob() 5 | return new Promise((onSuccess, onError) => { 6 | try { 7 | const reader = new FileReader() 8 | reader.onload = function() { onSuccess(this.result as string) } ; 9 | reader.readAsDataURL(blob) ; 10 | } catch (e) { 11 | onError(e) 12 | } 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /src/components/marker/VMarkerHeadCircle.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 23 | -------------------------------------------------------------------------------- /src/composables/layout.ts: -------------------------------------------------------------------------------- 1 | import { inject, InjectionKey, provide } from "vue" 2 | import { nonNull, Reactive } from "@/common/common" 3 | import { Layouts } from "@/common/types" 4 | 5 | const injectionKey = Symbol("layouts") as InjectionKey> 6 | 7 | export function provideLayouts(layouts: Reactive) { 8 | provide(injectionKey, layouts) 9 | } 10 | 11 | export function useLayouts(): Layouts { 12 | return nonNull(inject(injectionKey), "Layouts") 13 | } 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": false, 7 | "quoteProps": "as-needed", 8 | "trailingComma": "es5", 9 | "bracketSpacing": true, 10 | "arrowParens": "avoid", 11 | "rangeStart": 0, 12 | "filepath": "none", 13 | "requirePragma": false, 14 | "insertPragma": false, 15 | "proseWrap": "preserve", 16 | "htmlWhitespaceSensitivity": "css", 17 | "vueIndentScriptAndStyle": false, 18 | "endOfLine": "auto" 19 | } 20 | -------------------------------------------------------------------------------- /src/composables/event-emitter.ts: -------------------------------------------------------------------------------- 1 | import { provide, inject, InjectionKey } from "vue" 2 | import mitt, { Emitter } from "mitt" 3 | import { nonNull } from "@/common/common" 4 | import { Events } from "@/common/types" 5 | 6 | const eventEmitterKey = Symbol("emitter") as InjectionKey> 7 | 8 | export function provideEventEmitter(): Emitter { 9 | // event bus 10 | const emitter = mitt() 11 | provide(eventEmitterKey, emitter) 12 | return emitter 13 | } 14 | 15 | export function useEventEmitter(): Emitter { 16 | return nonNull(inject(eventEmitterKey), "event emitter") 17 | } 18 | -------------------------------------------------------------------------------- /src/layouts/grid.ts: -------------------------------------------------------------------------------- 1 | import { Ref } from "vue" 2 | import { Position } from "@/common/types" 3 | import { SimpleLayout } from "./simple" 4 | 5 | const DEFAULT_GRID = 10 6 | 7 | export type GridLayoutParameters = { 8 | grid?: number 9 | } 10 | 11 | export class GridLayout extends SimpleLayout { 12 | constructor(private options: GridLayoutParameters = {}) { 13 | super() 14 | } 15 | 16 | protected setNodePosition(nodeLayout: Ref, pos: Position) { 17 | const grid = this.options.grid || DEFAULT_GRID 18 | nodeLayout.value.x = Math.floor(pos.x / grid) * grid 19 | nodeLayout.value.y = Math.floor(pos.y / grid) * grid 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "jsx": "preserve", 8 | "sourceMap": true, 9 | "resolveJsonModule": true, 10 | "esModuleInterop": true, 11 | "lib": ["esnext", "dom"], 12 | "types": ["vue", "node"], 13 | "baseUrl": "./", 14 | "forceConsistentCasingInFileNames": true, 15 | "importHelpers": true, 16 | "skipLibCheck": true, 17 | "paths": { 18 | "@/*": ["./src/*"], 19 | } 20 | }, 21 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 22 | "exclude": ["**/*.spec.ts"] 23 | } 24 | -------------------------------------------------------------------------------- /src/components/marker/VMarkerHeadArrow.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 28 | -------------------------------------------------------------------------------- /src/components/edge/VEdgeBackgrounds.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 22 | -------------------------------------------------------------------------------- /src/components/svg/VSvgCircle.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 30 | -------------------------------------------------------------------------------- /src/models/node.ts: -------------------------------------------------------------------------------- 1 | import { ComputedRef, UnwrapRef, Ref } from "vue" 2 | import { ShapeStyle, NodeLabelStyle, OppositeNode } from "@/common/configs" 3 | 4 | export interface NodeStateDatum { 5 | id: string 6 | shape: ComputedRef 7 | staticShape: ComputedRef 8 | label: ComputedRef 9 | labelText: ComputedRef 10 | selected: boolean 11 | hovered: boolean 12 | draggable: ComputedRef 13 | selectable: ComputedRef 14 | zIndex: ComputedRef 15 | oppositeNodeIds: Ref> 16 | oppositeNodes: ComputedRef> 17 | } 18 | 19 | export type NodeState = UnwrapRef 20 | export type NodeStates = Record 21 | -------------------------------------------------------------------------------- /src/components/layers/VFocusringLayer.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 24 | -------------------------------------------------------------------------------- /src/components/base/VSelectionBox.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 24 | 25 | 30 | -------------------------------------------------------------------------------- /src/composables/zoom.ts: -------------------------------------------------------------------------------- 1 | import { provide, inject, InjectionKey, Ref, computed } from "vue" 2 | import { nonNull } from "@/common/common" 3 | import { ViewConfig } from "@/common/configs" 4 | 5 | interface ZoomProvides { 6 | zoomLevel: Ref, 7 | scale: Ref 8 | } 9 | 10 | const zoomLevelKey = Symbol("zoomLevel") as InjectionKey 11 | 12 | export function provideZoomLevel(zoomLevel: Ref, viewStyle: ViewConfig) { 13 | const scale = computed(() => { 14 | return viewStyle.scalingObjects ? 1 : (1 / zoomLevel.value) 15 | }) 16 | provide(zoomLevelKey, { 17 | zoomLevel, 18 | scale 19 | }) 20 | return { scale } 21 | } 22 | 23 | export function useZoomLevel(): ZoomProvides { 24 | return nonNull(inject(zoomLevelKey), "zoomLevel") 25 | } 26 | -------------------------------------------------------------------------------- /src/composables/selection.ts: -------------------------------------------------------------------------------- 1 | import { inject, InjectionKey, provide } from "vue" 2 | import { nonNull, Reactive } from "@/common/common" 3 | 4 | interface Selections { 5 | selectedNodes: Reactive> 6 | selectedEdges: Reactive> 7 | selectedPaths: Reactive> 8 | } 9 | 10 | const injectionKey = Symbol("selection") as InjectionKey 11 | 12 | export function provideSelections( 13 | selectedNodes: Reactive>, 14 | selectedEdges: Reactive>, 15 | selectedPaths: Reactive> 16 | ) { 17 | provide(injectionKey, { 18 | selectedNodes, 19 | selectedEdges, 20 | selectedPaths, 21 | }) 22 | } 23 | 24 | export function useSelections(): Selections { 25 | return nonNull(inject(injectionKey), "Selections") 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/object.ts: -------------------------------------------------------------------------------- 1 | import { isEqual } from "lodash-es" 2 | 3 | export function keyOf(obj: T): (keyof T)[] { 4 | return Object.keys(obj) as Array 5 | } 6 | 7 | export function entriesOf(obj: T): [K, T[K]][] { 8 | return Object.entries(obj) as [K, T[K]][] 9 | } 10 | 11 | export function updateObjectDiff>(target: T, from: T) { 12 | const keys = new Set(Object.keys(target)) 13 | entriesOf(from).forEach(([key, value]) => { 14 | if (!isEqual(target[key], value)) { 15 | target[key] = value 16 | } 17 | keys.delete(key) 18 | }) 19 | keys.forEach(k => delete target[k]) 20 | } 21 | 22 | export function isPromise(obj: any): boolean { 23 | return obj instanceof Promise || (obj && typeof obj.then === 'function') 24 | } 25 | -------------------------------------------------------------------------------- /src/layouts/handler.ts: -------------------------------------------------------------------------------- 1 | import { Ref } from "vue" 2 | import { Emitter } from "mitt" 3 | import { Reactive } from "@/common/common" 4 | import { Events, Edges, NodePositions, Nodes } from "@/common/types" 5 | import { Configs } from "@/common/configs" 6 | import { SvgPanZoomInstance } from "@/modules/svg-pan-zoom-ex" 7 | 8 | export interface LayoutActivateParameters { 9 | layouts: Reactive // deprecated. 10 | // Unable to track objects in `layouts.nodes` when rewriting reference. 11 | 12 | nodePositions: Ref 13 | nodes: Ref 14 | edges: Ref 15 | configs: Readonly 16 | scale: Readonly> 17 | emitter: Emitter 18 | svgPanZoom: SvgPanZoomInstance 19 | } 20 | 21 | export interface LayoutHandler { 22 | activate(parameters: LayoutActivateParameters): void 23 | deactivate(): void 24 | } 25 | -------------------------------------------------------------------------------- /src/components/svg/VSvgRect.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 34 | -------------------------------------------------------------------------------- /src/components/layers/VEdgesLayer.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 26 | -------------------------------------------------------------------------------- /src/models/path.ts: -------------------------------------------------------------------------------- 1 | import { ComputedRef, UnwrapRef, WatchStopHandle } from "vue" 2 | import { Edge, Path } from "@/common/types" 3 | import { VectorLine } from "@/modules/calculation/line" 4 | import { Arc, Curve } from "@/models/edge" 5 | 6 | export interface EdgeObject { 7 | edgeId: string 8 | edge: Edge 9 | } 10 | 11 | export interface PathStateDatum { 12 | id: string 13 | selected: boolean 14 | hovered: boolean 15 | selectable: ComputedRef 16 | zIndex: ComputedRef 17 | 18 | clickable: ComputedRef 19 | hoverable: ComputedRef 20 | path: Path 21 | edges: EdgeObject[] 22 | directions: boolean[] 23 | stopWatchHandle: WatchStopHandle 24 | } 25 | 26 | export type PathState = UnwrapRef 27 | export type PathStates = Record 28 | 29 | export interface EdgeLine { 30 | edgeId: string 31 | source: string 32 | target: string 33 | line: VectorLine 34 | direction: boolean 35 | curve?: Curve 36 | loop?: Arc 37 | } 38 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.run": "onType", 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": "explicit" 5 | }, 6 | "eslint.format.enable": true, 7 | "cSpell.words": [ 8 | "composables", 9 | "focusring", 10 | "hoverable", 11 | "nuxt", 12 | "vite", 13 | "vitejs", 14 | "vitest" 15 | ], 16 | "cSpell.ignorePaths": [ 17 | ".git", 18 | ".vscode", 19 | "node_modules", 20 | "package.json", 21 | "package-lock.json", 22 | ".prettierrc", 23 | ".eslintrc.cjs" 24 | ], 25 | "[javascript]": { 26 | "editor.tabSize": 2, 27 | "editor.defaultFormatter": "esbenp.prettier-vscode" 28 | }, 29 | "[typescript]": { 30 | "editor.tabSize": 2, 31 | "editor.defaultFormatter": "esbenp.prettier-vscode" 32 | }, 33 | "[vue]": { 34 | "editor.tabSize": 2, 35 | "editor.defaultFormatter": "esbenp.prettier-vscode" 36 | }, 37 | "[json]": { 38 | "editor.tabSize": 2, 39 | "editor.defaultFormatter": "esbenp.prettier-vscode" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/marker/VMarkerHeadAngle.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 40 | -------------------------------------------------------------------------------- /src/modules/node/node.ts: -------------------------------------------------------------------------------- 1 | import { AnyShapeStyle } from "@/common/configs" 2 | import { Box, NodePositions } from "@/common/types" 3 | 4 | export function getNodeRadius(shape: AnyShapeStyle) { 5 | if (shape.type == "circle") { 6 | return shape.radius 7 | } else { 8 | return Math.min(shape.width, shape.height) / 2 9 | } 10 | } 11 | 12 | export function getNodesBox(layouts: NodePositions): Box { 13 | const positions = Object.values(layouts) 14 | if (positions.length === 0) { 15 | return { 16 | top: 0, 17 | bottom: 0, 18 | left: 0, 19 | right: 0, 20 | } 21 | } 22 | 23 | const result = { 24 | top: positions[0].y, 25 | bottom: positions[0].y, 26 | left: positions[0].x, 27 | right: positions[0].x, 28 | } 29 | 30 | positions.forEach(pos => { 31 | result.top = Math.min(pos.y, result.top) 32 | result.bottom = Math.max(pos.y, result.bottom) 33 | result.left = Math.min(pos.x, result.left) 34 | result.right = Math.max(pos.x, result.right) 35 | }) 36 | 37 | return result 38 | } 39 | -------------------------------------------------------------------------------- /src/components/svg/VSvgPath.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 29 | 30 | 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright © 2021-2024 dash14.ack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/svg/VSvgLine.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 42 | -------------------------------------------------------------------------------- /src/composables/container.ts: -------------------------------------------------------------------------------- 1 | import { provide, inject, InjectionKey, Ref } from "vue" 2 | import { SvgPanZoomInstance } from "@/modules/svg-pan-zoom-ex" 3 | import { nonNull } from "@/common/common" 4 | 5 | interface ProvideContainers { 6 | container: Ref 7 | svg: Ref 8 | viewport: Ref 9 | svgPanZoom: Ref 10 | } 11 | 12 | interface Containers { 13 | container: Ref 14 | svg: Ref 15 | viewport: Ref 16 | svgPanZoom: Ref 17 | } 18 | 19 | const containersKey = Symbol("containers") as InjectionKey 20 | 21 | export function provideContainers(containers: Containers): void { 22 | provide(containersKey, containers) 23 | } 24 | 25 | export function useContainers(): ProvideContainers { 26 | const containers = nonNull(inject(containersKey), "containers") 27 | return { 28 | container: containers.container as Ref, 29 | svg: containers.svg as Ref, 30 | viewport: containers.viewport as Ref, 31 | svgPanZoom: containers.svgPanZoom 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/components/layers/VEdgeLabelsLayer.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 38 | -------------------------------------------------------------------------------- /tests/modules/collection/iterate.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest" 2 | import { pairwise } from "@/modules/collection/iterate" 3 | 4 | describe("pairwise", () => { 5 | describe("two elements", () => { 6 | const target = ["abc", "def"] 7 | const result: [string, string][] = [] 8 | pairwise(target, (p1, p2) => result.push([p1, p2])) 9 | it("should call once", () => { 10 | expect(1).to.be.equal(result.length) 11 | expect([["abc", "def"]]).to.be.eql(result) 12 | }) 13 | }) 14 | 15 | describe("three elements", () => { 16 | const target = ["abc", "def", "ghi"] 17 | const result: [string, string][] = [] 18 | pairwise(target, (p1, p2) => result.push([p1, p2])) 19 | it("should call twice", () => { 20 | expect(2).to.be.equal(result.length) 21 | expect([ 22 | ["abc", "def"], 23 | ["def", "ghi"], 24 | ]).to.be.eql(result) 25 | }) 26 | }) 27 | 28 | describe("one element", () => { 29 | const target = ["abc"] 30 | const result: [string, string][] = [] 31 | pairwise(target, (p1, p2) => result.push([p1, p2])) 32 | it("should not call", () => { 33 | expect(0).to.be.equal(result.length) 34 | expect([]).to.be.eql(result) 35 | }) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /tests/modules/node/node.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | import { NodePositions } from "@/common/types" 3 | import { getNodesBox } from "@/modules/node/node" 4 | 5 | describe("node", () => { 6 | describe("getNodesBox", () => { 7 | it("should return a box if there are more than two nodes", () => { 8 | const layouts: NodePositions = { 9 | node1: { x: 10, y: 20 }, 10 | node2: { x: 30, y: 40 }, 11 | } 12 | const box = getNodesBox(layouts) 13 | expect(box).toStrictEqual({ 14 | top: 20, 15 | left: 10, 16 | right: 30, 17 | bottom: 40, 18 | }) 19 | }) 20 | 21 | it("should return a point if the node is one", () => { 22 | const layouts: NodePositions = { 23 | node1: { x: 10, y: 20 }, 24 | } 25 | const box = getNodesBox(layouts) 26 | expect(box).toStrictEqual({ 27 | top: 20, 28 | left: 10, 29 | right: 10, 30 | bottom: 20, 31 | }) 32 | }) 33 | 34 | it("should return all-zero value box when there are 0 nodes", () => { 35 | const layouts: NodePositions = {} 36 | const box = getNodesBox(layouts) 37 | expect(box).toStrictEqual({ top: 0, left: 0, right: 0, bottom: 0 }) 38 | }) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /src/components/base/VLine.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 43 | -------------------------------------------------------------------------------- /src/common/common.ts: -------------------------------------------------------------------------------- 1 | import { isReactive, reactive } from "vue" 2 | 3 | /* ------------------------------------------ * 4 | * Utility 5 | * ------------------------------------------ */ 6 | 7 | export type RecursivePartial = { 8 | [P in keyof T]?: T[P] extends (infer U)[] 9 | ? RecursivePartial[] 10 | : T[P] extends (infer U)[] | undefined 11 | ? RecursivePartial[] 12 | : // eslint-disable-next-line @typescript-eslint/ban-types 13 | T[P] extends object 14 | ? RecursivePartial 15 | : // eslint-disable-next-line @typescript-eslint/ban-types 16 | T[P] extends object | undefined 17 | ? RecursivePartial 18 | : T[P] 19 | } 20 | 21 | declare class Id { 22 | private IDENTITY: T 23 | } 24 | 25 | export type Reactive = Id<"Reactive"> & T 26 | 27 | // eslint-disable-next-line @typescript-eslint/ban-types 28 | export function Reactive(value: T): Reactive { 29 | if (isReactive(value)) { 30 | return value as Reactive 31 | } else { 32 | return reactive(value) as unknown as Reactive 33 | } 34 | } 35 | 36 | export interface ReadonlyRef { 37 | readonly value: T 38 | } 39 | 40 | export function nonNull(val?: T | null, name = "Parameter"): T { 41 | if (val === undefined || val === null) { 42 | throw new Error(`${name} is null`) 43 | } 44 | return val 45 | } 46 | -------------------------------------------------------------------------------- /src/components/path/VPaths.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 40 | -------------------------------------------------------------------------------- /src/components/background/VBackgroundViewport.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 44 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { App, Plugin } from "vue" 2 | 3 | // Import vue components 4 | import * as components from "./components/index" 5 | 6 | // install function executed by Vue.use() 7 | const install: Exclude = function (app: App) { 8 | Object.entries(components).forEach(([componentName, component]) => { 9 | app.component(componentName, component) 10 | }) 11 | } 12 | 13 | // Create module definition for Vue.use() 14 | export default install 15 | 16 | export type VNetworkGraphInstance = InstanceType 17 | export type Instance = InstanceType 18 | 19 | // To allow individual component use, export components 20 | // each can be registered via Vue.component() 21 | export * from "./components/index" 22 | 23 | export { getFullConfigs } from "./common/config-defaults" 24 | 25 | export { SimpleLayout } from "./layouts/simple" 26 | export { GridLayout } from "./layouts/grid" 27 | // export { ForceLayout } from "./layouts/force" 28 | export type { LayoutHandler } from "./layouts/handler" 29 | 30 | export * from "./common/types" 31 | export * from "./common/configs" 32 | export { Vector2D } from "./modules/vector2d" 33 | 34 | // Export for more advanced visualization. However, be aware of the 35 | // possibility of destructive specification changes in the future. 36 | export { useStates } from "./composables/state" 37 | 38 | export type { Box } from "./modules/svg-pan-zoom-ex" 39 | -------------------------------------------------------------------------------- /BACKERS.md: -------------------------------------------------------------------------------- 1 |

Sponsors & Backers

2 | 3 | v-network-graph is a MIT-licensed open source project with its ongoing development 4 | made possible thanks to the support of the awesome awesome sponsors and backers 5 | listed in this file. 6 | If you'd like to join them, please consider sponsoring v-network-graph development. 7 | 8 | * [GitHub Sponsors](https://github.com/sponsors/dash14) 9 | 10 | [!["Github Sponsors"](https://img.shields.io/badge/sponsor-30363D?style=for-the-badge&logo=GitHub-Sponsors&logoColor=#EA4AAA)](https://github.com/sponsors/dash14) 11 | 12 | * [Buy Me A Coffee](https://www.buymeacoffee.com/dash14.ack) 13 | 14 | [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/dash14.ack) 15 | 16 | --- 17 | 18 | ## Backers/Sponsors via GitHub Sponsors 19 | 20 | * Robin Oster ([@prine](https://github.com/prine)) 21 | * Sev ([@sevsev9](https://github.com/sevsev9)) 22 | 23 | *[Become a sponsor](https://github.com/sponsors/dash14)* 24 | 25 | --- 26 | 27 | ## Backers via Buy Me A Coffee ☕️ 28 | 29 | ### Generous Backer (100 coffees! ☕️) 30 | 31 | * [@prinedev](https://twitter.com/prinedev) 32 | 33 | ### Backer 34 | 35 | * Zulfi 36 | * Michael Dunn 37 | * Lukas Hrmo 38 | * viert 39 | * kyle42walker 40 | * dtk 41 | * [@RainBoltz](https://twitter.com/RainBoltz) 42 | * violent-boomerang 43 | * [@steffenBle](https://twitter.com/steffenBle) 44 | 45 | * [@EbnerMarkus49](https://twitter.com/EbnerMarkus49) 46 | * Adrien Ortola 47 | * hank.wang 48 | -------------------------------------------------------------------------------- /vite-umd-force.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import { defineConfig } from "vite" 3 | import vue from "@vitejs/plugin-vue" 4 | import { visualizer } from "rollup-plugin-visualizer" 5 | 6 | const resolvePath = (str: string) => path.resolve(__dirname, str) 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig({ 10 | resolve: { 11 | alias: [{ find: "@/", replacement: path.join(__dirname, "./src/") }], 12 | }, 13 | build: { 14 | target: "es2015", 15 | lib: { 16 | formats: ["umd"], 17 | entry: resolvePath("src/force-layout.umd.ts"), 18 | name: "VNetworkGraphForceLayout", 19 | fileName: () => "force-layout.js", 20 | }, 21 | emptyOutDir: false, 22 | rollupOptions: { 23 | // make sure to externalize deps that shouldn't be bundled 24 | // into your library 25 | external: ["vue", "d3-force"], 26 | output: { 27 | exports: "named", 28 | dir: resolvePath("umd"), 29 | // Provide global variables to use in the UMD build 30 | // for externalized deps 31 | globals: { 32 | vue: "Vue", 33 | "d3-force": "d3", 34 | }, 35 | }, 36 | }, 37 | sourcemap: true, 38 | }, 39 | css: { 40 | preprocessorOptions: { 41 | scss: { 42 | api: "modern-compiler" 43 | } 44 | } 45 | }, 46 | publicDir: false, 47 | plugins: [ 48 | vue(), 49 | visualizer({ 50 | filename: "stats-umd-force.html", 51 | gzipSize: true, 52 | brotliSize: true, 53 | }), 54 | ], 55 | }) 56 | -------------------------------------------------------------------------------- /vite-umd-main.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import { defineConfig } from "vite" 3 | import vue from "@vitejs/plugin-vue" 4 | import { visualizer } from "rollup-plugin-visualizer" 5 | 6 | const resolvePath = (str: string) => path.resolve(__dirname, str) 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig({ 10 | resolve: { 11 | alias: [{ find: "@/", replacement: path.join(__dirname, "./src/") }], 12 | }, 13 | build: { 14 | target: "es2015", 15 | lib: { 16 | formats: ["umd"], 17 | entry: resolvePath("src/index.umd.ts"), 18 | name: "VNetworkGraph", 19 | fileName: () => "index.js", 20 | cssFileName: "style", 21 | }, 22 | emptyOutDir: false, 23 | rollupOptions: { 24 | // make sure to externalize deps that shouldn't be bundled 25 | // into your library 26 | external: ["vue"], // bundle lodash-es, mitt 27 | output: { 28 | exports: "named", 29 | dir: resolvePath("umd"), 30 | // Provide global variables to use in the UMD build 31 | // for externalized deps 32 | globals: { 33 | vue: "Vue", 34 | }, 35 | }, 36 | }, 37 | cssCodeSplit: false, 38 | sourcemap: true, 39 | }, 40 | css: { 41 | preprocessorOptions: { 42 | scss: { 43 | api: "modern-compiler" 44 | } 45 | } 46 | }, 47 | publicDir: false, 48 | plugins: [ 49 | vue(), 50 | visualizer({ 51 | filename: "stats-umd-main.html", 52 | gzipSize: true, 53 | brotliSize: true, 54 | }), 55 | ], 56 | }) 57 | -------------------------------------------------------------------------------- /src/composables/transition.ts: -------------------------------------------------------------------------------- 1 | import { nextTick, ref } from "vue" 2 | 3 | type TimingFunction = "ease" | "linear" | "ease-in" | "ease-out" | "ease-in-out" | string 4 | 5 | /** Parameters of transition */ 6 | interface TransitionParameters { 7 | enabled: boolean 8 | duration: number 9 | timingFunction: TimingFunction 10 | } 11 | 12 | type CallbackFunction = () => void | Promise 13 | 14 | function isPromise(obj: any): boolean { 15 | return obj instanceof Promise || (obj && typeof obj.then === "function") 16 | } 17 | 18 | export function useTransitionWhile() { 19 | let timerId: number | null = null 20 | const transitionOption = ref({ 21 | enabled: false, 22 | duration: 300, 23 | timingFunction: "linear" 24 | }) 25 | 26 | function transitionWhile( 27 | func: CallbackFunction, 28 | duration = 300, 29 | timingFunction: TimingFunction = "linear" 30 | ) { 31 | if (timerId) { 32 | clearTimeout(timerId) 33 | timerId = null 34 | } 35 | transitionOption.value = { 36 | enabled: true, 37 | duration, 38 | timingFunction 39 | } 40 | 41 | nextTick(async () => { 42 | const promise = func() 43 | if (isPromise(promise)) { 44 | await promise 45 | } 46 | 47 | if (timerId) { 48 | clearTimeout(timerId) 49 | } 50 | timerId = window?.setTimeout(() => { 51 | transitionOption.value.enabled = false 52 | timerId = null 53 | }, duration) 54 | }) 55 | } 56 | 57 | return { transitionWhile, transitionOption } 58 | } 59 | 60 | -------------------------------------------------------------------------------- /src/composables/config.ts: -------------------------------------------------------------------------------- 1 | import { inject, InjectionKey, provide, reactive, Ref, watch } from "vue" 2 | import { isPlainObject, merge, mergeWith } from "lodash-es" 3 | import { nonNull } from "@/common/common" 4 | import { Configs, UserConfigs } from "@/common/configs" 5 | import { getConfigDefaults } from "@/common/config-defaults" 6 | 7 | const injectionKey = Symbol("style") as InjectionKey 8 | 9 | function merger(destination: any, source: any) { 10 | if (isPlainObject(destination)) { 11 | return merge(destination, source) 12 | } else { 13 | return source // overwrite 14 | } 15 | } 16 | 17 | export function provideConfigs(configs: Ref) { 18 | const results: Configs = reactive(getConfigDefaults()) 19 | const styleKeys = Object.keys(results) as (keyof Configs)[] 20 | for (const key of styleKeys) { 21 | watch(() => configs.value[key], () => { 22 | mergeWith(results[key], configs.value[key] || {}, merger) 23 | }, { immediate: true, deep: true }) 24 | } 25 | 26 | provide(injectionKey, results) 27 | return results 28 | } 29 | 30 | function injectConfig(key: T) { 31 | return nonNull(inject(injectionKey), `Configs(${key})`)[key] 32 | } 33 | 34 | export function useAllConfigs() { 35 | return nonNull(inject(injectionKey)) 36 | } 37 | 38 | export function useViewConfig() { 39 | return injectConfig("view") 40 | } 41 | 42 | export function useNodeConfig() { 43 | return injectConfig("node") 44 | } 45 | 46 | export function useEdgeConfig() { 47 | return injectConfig("edge") 48 | } 49 | 50 | export function usePathConfig() { 51 | return injectConfig("path") 52 | } 53 | -------------------------------------------------------------------------------- /src/components/edge/VEdgeLabelPlace.vue: -------------------------------------------------------------------------------- 1 | 8 | 48 | 60 | -------------------------------------------------------------------------------- /src/components/edge/VEdgeLabelsPlace.vue: -------------------------------------------------------------------------------- 1 | 8 | 46 | 57 | -------------------------------------------------------------------------------- /src/components/base/VArc.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 55 | -------------------------------------------------------------------------------- /tests/modules/collection/array.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest" 2 | import { insertAfter, removeItem } from "@/modules/collection/array" 3 | 4 | describe("removeItem", () => { 5 | describe("first element", () => { 6 | const target = ["abc", "def", "ghi"] 7 | removeItem(target, "abc") 8 | it("should be removed first element", () => { 9 | expect(["def", "ghi"]).to.be.eql(target) 10 | }) 11 | }) 12 | 13 | describe("last element", () => { 14 | const target = ["abc", "def", "ghi"] 15 | removeItem(target, "ghi") 16 | it("should be removed last element", () => { 17 | expect(["abc", "def"]).to.be.eql(target) 18 | }) 19 | }) 20 | 21 | describe("not found element", () => { 22 | const target = ["abc", "def", "ghi"] 23 | removeItem(target, "jkl") 24 | it("should be removed last element", () => { 25 | expect(["abc", "def", "ghi"]).to.be.eql(target) 26 | }) 27 | }) 28 | }) 29 | 30 | describe("insertAfter", () => { 31 | describe("first element", () => { 32 | const target = ["abc", "ghi"] 33 | insertAfter(target, "abc", "def") 34 | it("should be inserted after abc", () => { 35 | expect(["abc", "def", "ghi"]).to.be.eql(target) 36 | }) 37 | }) 38 | 39 | describe("last element", () => { 40 | const target = ["abc", "def"] 41 | insertAfter(target, "def", "ghi") 42 | it("should be inserted after def", () => { 43 | expect(["abc", "def", "ghi"]).to.be.eql(target) 44 | }) 45 | }) 46 | 47 | describe("not found element", () => { 48 | const target = ["abc", "def"] 49 | removeItem(target, "ghi") 50 | it("should be not inserted", () => { 51 | expect(["abc", "def"]).to.be.eql(target) 52 | }) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /src/composables/object.ts: -------------------------------------------------------------------------------- 1 | import { ref, Ref, watchEffect } from "vue" 2 | import { InputPaths, Path } from "@/common/types" 3 | 4 | export function useTranslatePathsToObject(input: Ref) { 5 | const objects = ref>({}) 6 | 7 | const isInCompatibilityModeForPath = ref(false) 8 | let nextId = 1 9 | const idStore = new Map() 10 | 11 | // translate for compatibility 12 | watchEffect(() => { 13 | if (input.value instanceof Array) { 14 | const containKeys = new Set([]) 15 | objects.value = Object.fromEntries( 16 | input.value.map(path => { 17 | let id = path.id 18 | if (!id) { 19 | if (!isInCompatibilityModeForPath.value) { 20 | isInCompatibilityModeForPath.value = true 21 | console.warn( 22 | "[v-network-graph] Please specify the `id` field for the `Path` object." + 23 | " Currently, this works for compatibility," + 24 | " but in the future, the id field will be required." 25 | ) 26 | } 27 | id = idStore.get(path) 28 | if (!id) { 29 | id = "path-" + nextId++ 30 | idStore.set(path, id) 31 | } 32 | } 33 | containKeys.add(id) 34 | return [id, path] 35 | }) 36 | ) 37 | if (isInCompatibilityModeForPath.value) { 38 | for (const [path, id] of Array.from(idStore.entries())) { 39 | if (!containKeys.has(id)) { 40 | idStore.delete(path) 41 | } 42 | } 43 | } 44 | } else { 45 | objects.value = input.value 46 | } 47 | }) 48 | 49 | return { objects, isInCompatibilityModeForPath } 50 | } 51 | -------------------------------------------------------------------------------- /src/composables/layer.ts: -------------------------------------------------------------------------------- 1 | import { computed, ComputedRef, Slot } from "vue" 2 | import { uniq } from "lodash-es" 3 | import { Configs } from "@/common/configs" 4 | import { LayerName } from "@/common/types" 5 | import { pairwise } from "@/modules/collection/iterate" 6 | import { insertAfter, removeItem } from "@/modules/collection/array" 7 | 8 | export interface LayerSlotProps { 9 | scale: number 10 | } 11 | export type LayerSlots = Record> 12 | 13 | export function useBuiltInLayerOrder( 14 | configs: T, 15 | slots: Readonly 16 | ): ComputedRef { 17 | const builtInLayers: Readonly = [ 18 | "edges", 19 | "edge-labels", 20 | "focusring", 21 | "nodes", 22 | "node-labels", 23 | "paths", 24 | ] as const 25 | 26 | return computed(() => { 27 | const request = uniq(configs.view.builtInLayerOrder) 28 | .filter(layer => { 29 | const defined = builtInLayers.includes(layer) 30 | if (!defined) { 31 | console.warn(`Layer ${layer} is not a built-in layer.`) 32 | } 33 | return defined 34 | }) 35 | .reverse() 36 | const order = [...builtInLayers] 37 | pairwise(request, (lower, higher) => { 38 | removeItem(order, higher) 39 | insertAfter(order, lower, higher) 40 | }) 41 | 42 | // Remove unused layers 43 | if (!("edge-label" in slots || "edges-label" in slots)) { 44 | removeItem(order, "edge-labels") 45 | } 46 | if (!configs.node.focusring.visible) { 47 | removeItem(order, "focusring") 48 | } 49 | if (configs.node.label.visible === false) { 50 | removeItem(order, "node-labels") 51 | } 52 | if (!configs.path.visible) { 53 | removeItem(order, "paths") 54 | } 55 | 56 | return order 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { Linter } from "eslint" 2 | import globals from "globals" 3 | import eslint from "@eslint/js"; 4 | import vueParser from "vue-eslint-parser" 5 | import tsPlugin from "typescript-eslint"; 6 | import vuePlugin from "eslint-plugin-vue" 7 | import importPlugin from "eslint-plugin-import" 8 | 9 | /** @type {Linter.Config[]} */ 10 | export default [ 11 | eslint.configs.recommended, 12 | importPlugin.flatConfigs.recommended, 13 | ...vuePlugin.configs["flat/base"], 14 | ...vuePlugin.configs["flat/recommended"], 15 | ...tsPlugin.configs.recommended, 16 | { 17 | languageOptions: { 18 | globals: { 19 | ...globals.node, 20 | ...globals.es2021, 21 | }, 22 | parser: vueParser, 23 | parserOptions: { 24 | extraFileExtensions: [".vue"], 25 | ecmaVersion: 12, 26 | parser: tsPlugin.parser, 27 | sourceType: "module", 28 | }, 29 | }, 30 | rules: { 31 | "import/order": [ 32 | "error", 33 | { groups: ["builtin", "external", "internal", "parent", "sibling", "index", "object"] }, 34 | ], 35 | "no-console": process.env.NODE_ENV === "production" ? 2 : 0, 36 | "import/no-duplicates": 0, 37 | "vue/singleline-html-element-content-newline": 0, 38 | "vue/multiline-html-element-content-newline": 0, 39 | "vue/max-attributes-per-line": ["error", { singleline: 3, multiline: 1 }], 40 | "vue/attribute-hyphenation": ["warn", "always", { ignore: ["custom-prop"] }], 41 | "@typescript-eslint/explicit-module-boundary-types": 0, 42 | "@typescript-eslint/no-explicit-any": 0, 43 | "@typescript-eslint/no-empty-function": 0, 44 | "no-unused-vars": 0, 45 | "@typescript-eslint/no-unused-vars": [ 46 | "warn", 47 | { args: "after-used", argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, 48 | ], 49 | }, 50 | settings: { 51 | "import/resolver": { typescript: [] }, 52 | }, 53 | }, 54 | ] 55 | -------------------------------------------------------------------------------- /src/utils/box.ts: -------------------------------------------------------------------------------- 1 | import { Box, ViewBox } from "@/common/types" 2 | 3 | export function areBoxesSame(box1: ViewBox, box2: ViewBox): boolean { 4 | // Compare with sufficient precision to be considered identical, 5 | // taking into account the resolution at which they are displayed. 6 | const error = Math.max(box1.width, box1.height, box2.width, box2.height) / 10000 7 | return ( 8 | Math.abs(box1.x - box2.x) < error && 9 | Math.abs(box1.y - box2.y) < error && 10 | Math.abs(box1.width - box2.width) < error && 11 | Math.abs(box1.height - box2.height) < error 12 | ) 13 | } 14 | 15 | export function boxAdd(box1: Box, box2: Box): Box { 16 | return { 17 | top: box1.top + box2.top, 18 | left: box1.left + box2.left, 19 | right: box1.right + box2.right, 20 | bottom: box1.bottom + box2.bottom, 21 | } 22 | } 23 | 24 | export function boxMultiply(box: Box, m: number): Box { 25 | return { 26 | top: box.top * m, 27 | left: box.left * m, 28 | right: box.right * m, 29 | bottom: box.bottom * m, 30 | } 31 | } 32 | 33 | export function boxDivide(box: Box, d: number): Box { 34 | return { 35 | top: box.top / d, 36 | left: box.left / d, 37 | right: box.right / d, 38 | bottom: box.bottom / d, 39 | } 40 | } 41 | 42 | export function viewBoxToBox(viewBox: ViewBox): Box { 43 | return { 44 | top: viewBox.y, 45 | left: viewBox.x, 46 | right: viewBox.x + viewBox.width, 47 | bottom: viewBox.y + viewBox.height, 48 | } 49 | } 50 | 51 | export function boxToViewBox(box: Box): ViewBox { 52 | return { 53 | x: box.left, 54 | y: box.top, 55 | width: box.right - box.left, 56 | height: box.bottom - box.top, 57 | } 58 | } 59 | 60 | export function mergeBox(box1: Box, box2: Box): Box { 61 | return { 62 | top: Math.min(box1.top, box2.top), 63 | left: Math.min(box1.left, box2.left), 64 | right: Math.max(box1.right, box2.right), 65 | bottom: Math.max(box1.bottom, box2.bottom), 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/components/marker/VMarkerHead.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 65 | -------------------------------------------------------------------------------- /src/components/layers/VNodesLayer.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 62 | -------------------------------------------------------------------------------- /src/components/base/VShape.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 77 | -------------------------------------------------------------------------------- /src/components/layers/VNodeLabelsLayer.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 66 | -------------------------------------------------------------------------------- /src/components/edge/VEdge.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 58 | 59 | 69 | -------------------------------------------------------------------------------- /src/components/edge/VEdgeCurved.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 77 | -------------------------------------------------------------------------------- /src/modules/vector2d/vector2d.ts: -------------------------------------------------------------------------------- 1 | import { Point2D } from "./core" 2 | import { add, angle, angleDegree, cross, distance, distanceSquared, divide } from "./methods" 3 | import { dot, length, lengthSquared, multiply, multiplyScalar, normalize } from "./methods" 4 | import { rotate, subtract } from "./methods" 5 | 6 | export class Vector2D implements Point2D { 7 | public x: number 8 | public y: number 9 | 10 | static fromArray(array: number[]) { 11 | return new Vector2D(array[0] || 0, array[1] || 0) 12 | } 13 | 14 | static fromObject(obj: Point2D) { 15 | return new Vector2D(obj.x, obj.y) 16 | } 17 | 18 | constructor(x: number, y: number) { 19 | this.x = x 20 | this.y = y 21 | } 22 | 23 | // instance methods 24 | add(v: Point2D): Vector2D { 25 | return add(this, v, this) 26 | } 27 | 28 | subtract(v: Point2D): Vector2D { 29 | return subtract(this, v, this) 30 | } 31 | 32 | multiply(v: Point2D): Vector2D { 33 | return multiply(this, v, this) 34 | } 35 | 36 | multiplyScalar(scalar: number): Vector2D { 37 | return multiplyScalar(this, scalar, this) 38 | } 39 | 40 | divide(v: Point2D): Vector2D { 41 | return divide(this, v, this) 42 | } 43 | 44 | dot(v: Point2D): number { 45 | return dot(this, v) 46 | } 47 | 48 | cross(v: Point2D): number { 49 | return cross(this, v) 50 | } 51 | 52 | lengthSquared(): number { 53 | return lengthSquared(this) 54 | } 55 | 56 | length(): number { 57 | return length(this) 58 | } 59 | 60 | distanceSquared(v: Point2D): number { 61 | return distanceSquared(this, v) 62 | } 63 | 64 | distance(v: Point2D): number { 65 | return distance(this, v) 66 | } 67 | 68 | normalize(): Vector2D { 69 | return normalize(this, this) 70 | } 71 | 72 | angle(): number { 73 | return angle(this) 74 | } 75 | 76 | angleDegree(): number { 77 | return angleDegree(this) 78 | } 79 | 80 | rotate(angle: number): Vector2D { 81 | return rotate(this, angle, this) 82 | } 83 | 84 | isEqualTo(v: Point2D): boolean { 85 | return this.x === v.x && this.y === v.y 86 | } 87 | 88 | clone(): Vector2D { 89 | return new Vector2D(this.x, this.y) 90 | } 91 | 92 | toObject(): Point2D { 93 | return { x: this.x, y: this.y } 94 | } 95 | 96 | toArray(): [number, number] { 97 | return [this.x, this.y] 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "v-network-graph", 3 | "description": "An interactive network graph visualization component for Vue 3", 4 | "version": "0.9.21", 5 | "main": "./umd/index.js", 6 | "module": "./lib/index.js", 7 | "types": "./lib/index.d.ts", 8 | "type": "module", 9 | "license": "MIT", 10 | "homepage": "https://dash14.github.io/v-network-graph/", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/dash14/v-network-graph.git" 14 | }, 15 | "files": [ 16 | "lib", 17 | "umd" 18 | ], 19 | "scripts": { 20 | "lint": "eslint -c eslint.config.js **/*.vue **/*.ts **/*.spec.ts --fix", 21 | "format": "prettier --write **/*.vue **/*.ts **/*.spec.ts", 22 | "build": "run-s clean build:tc build:lib", 23 | "build:tc": "vue-tsc --noEmit", 24 | "build:lib": "run-p build:lib:*", 25 | "build:lib:es": "vite build", 26 | "build:lib:main": "vite --config vite-umd-main.config.ts build", 27 | "build:lib:force": "vite --config vite-umd-force.config.ts build", 28 | "clean": "rimraf lib umd", 29 | "test": "vitest run" 30 | }, 31 | "dependencies": { 32 | "@dash14/svg-pan-zoom": "^3.6.9", 33 | "lodash-es": "^4.17.21", 34 | "mitt": "^3.0.1" 35 | }, 36 | "devDependencies": { 37 | "@types/d3-force": "^3.0.10", 38 | "@types/lodash-es": "^4.17.12", 39 | "@types/node": "^20.17.28", 40 | "@vitejs/plugin-vue": "^5.2.3", 41 | "@vue/compiler-sfc": "^3.5.13", 42 | "eslint": "^9.23.0", 43 | "eslint-config-prettier": "^10.1.1", 44 | "eslint-import-resolver-typescript": "^3.10.0", 45 | "eslint-plugin-import": "^2.31.0", 46 | "eslint-plugin-prettier": "^5.2.5", 47 | "eslint-plugin-vue": "^9.33.0", 48 | "globals": "^16.0.0", 49 | "npm-run-all": "^4.1.5", 50 | "rimraf": "^6.0.1", 51 | "rollup-plugin-visualizer": "^5.14.0", 52 | "sass": "^1.86.0", 53 | "typescript": "^5.8.2", 54 | "typescript-eslint": "^8.28.0", 55 | "vite": "^6.2.3", 56 | "vite-plugin-dts": "^4.5.3", 57 | "vitest": "^3.0.9", 58 | "vue-tsc": "^2.2.8" 59 | }, 60 | "peerDependencies": { 61 | "d3-force": "^3.0.0", 62 | "vue": "^3.5.13" 63 | }, 64 | "peerDependenciesMeta": { 65 | "d3-force": { 66 | "optional": true 67 | } 68 | }, 69 | "exports": { 70 | ".": { 71 | "import": "./lib/index.js", 72 | "require": "./umd/index.js" 73 | }, 74 | "./lib/force-layout": { 75 | "import": "./lib/force-layout.js", 76 | "require": "./umd/force-layout.js" 77 | }, 78 | "./lib/*": "./lib/*" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/components/edge/VEdgeBackground.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 71 | 72 | 79 | -------------------------------------------------------------------------------- /src/components/edge/VEdgeGroups.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 83 | -------------------------------------------------------------------------------- /src/models/edge.ts: -------------------------------------------------------------------------------- 1 | import { ComputedRef, Ref, UnwrapRef , WatchStopHandle } from "vue" 2 | import { EdgeLabelStyle, MarkerStyle , StrokeStyle } from "@/common/configs" 3 | import { Edge, Edges , LinePosition, Position } from "@/common/types" 4 | import { Vector2D } from "@/modules/vector2d" 5 | 6 | export interface EdgeLayoutPoint { 7 | edge: Edge 8 | pointInGroup: number 9 | groupWidth: number 10 | } 11 | 12 | export interface EdgeGroup { 13 | edges: Edges 14 | groupWidth: number 15 | summarize: boolean 16 | } 17 | 18 | export interface EdgeGroupStates { 19 | edgeLayoutPoints: Record 20 | edgeGroups: Record 21 | summarizedEdges: Record 22 | } 23 | 24 | // States of edges 25 | export interface Line { 26 | stroke: StrokeStyle 27 | normalWidth: number // stroke width when not hovered 28 | source: MarkerStyle 29 | target: MarkerStyle 30 | } 31 | 32 | export interface Curve { 33 | center: Vector2D 34 | theta: number // theta: direction of source to center 35 | circle: { 36 | center: Vector2D 37 | radius: number 38 | } 39 | control: Position[] 40 | } 41 | 42 | export interface Arc { 43 | center: Vector2D 44 | radius: [number, number] 45 | isLargeArc: boolean 46 | isClockwise: boolean 47 | } 48 | 49 | export interface EdgeStateDatum { 50 | id: string 51 | line: Ref 52 | label: ComputedRef 53 | selectable: ComputedRef 54 | selected: boolean 55 | hovered: boolean 56 | origin: LinePosition // line segment between center of nodes 57 | labelPosition: LinePosition // line segment between the outermost of the nodes for labels 58 | position: LinePosition // line segments to be displayed with margins applied 59 | curve?: Curve 60 | loop?: Arc 61 | sourceMarkerId?: string 62 | targetMarkerId?: string 63 | zIndex: ComputedRef 64 | stopWatchHandle: WatchStopHandle 65 | } 66 | 67 | interface SummarizedEdgeStateDatum { 68 | stroke: Ref 69 | } 70 | 71 | export type EdgeState = UnwrapRef 72 | export type EdgeStates = Record 73 | export type SummarizedEdgeState = UnwrapRef 74 | export type SummarizedEdgeStates = Record 75 | 76 | // Edge item for display (an edge or summarized edges) 77 | export interface EdgeItem { 78 | id: string 79 | summarized: boolean 80 | key: string 81 | zIndex: number 82 | } 83 | 84 | export interface SummarizedEdgeItem extends EdgeItem { 85 | group: EdgeGroup 86 | } 87 | 88 | export interface SingleEdgeItem extends EdgeItem { 89 | edge: Edge 90 | } 91 | 92 | export type EdgeEntry = SummarizedEdgeItem | SingleEdgeItem 93 | -------------------------------------------------------------------------------- /src/utils/visual.ts: -------------------------------------------------------------------------------- 1 | import { Node, Position, Size } from "@/common/types" 2 | import { Config, NodeConfig, StrokeStyle } from "@/common/configs" 3 | 4 | export function getNodeSize(node: Node, style: NodeConfig, scale: number): Size { 5 | const shape = Config.values(style.normal, node) 6 | if (shape.type == "circle") { 7 | return { 8 | width: shape.radius * 2 * scale, 9 | height: shape.radius * 2 * scale, 10 | } 11 | } else { 12 | return { 13 | width: shape.width * scale, 14 | height: shape.height * scale, 15 | } 16 | } 17 | } 18 | 19 | export function areNodesCollision( 20 | nodePos: Position, 21 | nodeSize: Size, 22 | targetNodePos: Position, 23 | targetNodeSize: Size 24 | ): boolean { 25 | // x方向の衝突チェック 26 | const distanceX = Math.abs(nodePos.x - targetNodePos.x) 27 | const collisionX = distanceX < nodeSize.width / 2 + targetNodeSize.width / 2 28 | 29 | // y方向の衝突チェック 30 | const distanceY = Math.abs(nodePos.y - targetNodePos.y) 31 | const collisionY = distanceY < nodeSize.height / 2 + targetNodeSize.height / 2 32 | return collisionX && collisionY 33 | } 34 | 35 | export function applyScaleToDasharray(dasharray: number | string | undefined, scale: number) { 36 | let result: number | string = 0 37 | if (scale === 1 || dasharray === undefined || dasharray === "none") { 38 | result = dasharray ?? 0 39 | } else if (typeof dasharray === "string") { 40 | result = dasharray 41 | .split(/\s+/) 42 | .map(v => parseInt(v) * scale) 43 | .filter(v => !isNaN(v)) 44 | .join(" ") 45 | } else { 46 | result = dasharray * scale 47 | } 48 | return result && result !== "0" ? result : undefined 49 | } 50 | 51 | export function getDasharrayUnit(dasharray: number | string | undefined) { 52 | let result: number | string = 0 53 | if (dasharray === undefined || dasharray === "none") { 54 | result = 0 55 | } else if (typeof dasharray === "string") { 56 | const array = dasharray 57 | .split(/\s+/) 58 | .map(v => parseInt(v)) 59 | .filter(v => !isNaN(v)) 60 | if (array.length % 2 === 0) { 61 | // ex: 1 2 -> - - - - ... 62 | result = array.reduce((s, n) => s + n, 0) 63 | } else { 64 | // ex: 1 2 3 -> - --- -- - --- ... 65 | result = array.reduce((s, n) => s + n, 0) * 2 66 | } 67 | } else { 68 | result = dasharray * 2 // 2 <- border and space 69 | } 70 | return result 71 | } 72 | 73 | export function getAnimationSpeed(key: string, config: StrokeStyle, scale: number): Record { 74 | const speed = config.animate 75 | ? getDasharrayUnit(config.dasharray) * config.animationSpeed * scale 76 | : undefined 77 | return {[key]: speed} 78 | } 79 | -------------------------------------------------------------------------------- /src/utils/props.ts: -------------------------------------------------------------------------------- 1 | import { watch, reactive, ref, Ref } from "vue" 2 | import { isEqual } from "lodash-es" 3 | import { Reactive } from "../common/common" 4 | 5 | export function bindProp( 6 | props: T, 7 | name: K, 8 | emit: (event: `update:${K & string}`, ...args: any[]) => void, 9 | filter?: (arg: T[K]) => T[K] 10 | ): Ref { 11 | // Build two-way binding ties. 12 | 13 | // Since it is not always passed in props (emit does not 14 | // rewrite it), always keep a ref for self management. 15 | 16 | if (filter) { 17 | const prop = ref(filter(props[name])) as Ref 18 | const update = (filtered: T[K]) => { 19 | if (!isEqual(filtered, prop.value)) { 20 | prop.value = filtered 21 | } 22 | if (!isEqual(filtered, props[name])) { 23 | emit(`update:${name as string & K}`, filtered) 24 | } 25 | } 26 | watch(() => filter(prop.value), update) 27 | watch(() => props[name],v => update(filter(v))) 28 | if (prop.value !== props[name]) { 29 | emit(`update:${name as string & K}`, prop.value) 30 | } 31 | return prop 32 | } 33 | 34 | const prop = ref(props[name]) as Ref 35 | watch( 36 | () => props[name], 37 | v => { 38 | if (!isEqual(v, prop.value)) { 39 | prop.value = v 40 | } 41 | } 42 | ) 43 | watch(prop, v => { 44 | if (!isEqual(v, props[name])) { 45 | emit(`update:${name as string & K}`, v) 46 | } 47 | }) 48 | return prop 49 | } 50 | 51 | type KeysOfType = { 52 | [K in keyof Obj]-?: Obj[K] extends Val ? K : never 53 | }[keyof Obj] 54 | 55 | export function bindPropKeySet>( 56 | props: T, 57 | name: K, 58 | sourceObject: Ref<{ [name: string]: any }>, 59 | emit: (event: `update:${K & string}`, ...args: any[]) => void 60 | ): Reactive> { 61 | // Generate two-way bindings for a given prop. 62 | // Assumes that the specified prop indicates the key of the object. 63 | const bound = reactive>(new Set()) 64 | watch( 65 | () => props[name], 66 | () => { 67 | // Since it is not recognized as a string[] by type checking, 68 | // use any for now. 69 | const prop: string[] = props[name] as any 70 | const filtered = prop.filter(n => n in sourceObject.value) 71 | if (!isEqual(filtered, Array.from(bound))) { 72 | bound.clear() 73 | filtered.forEach(bound.add, bound) 74 | } 75 | }, 76 | { deep: true, immediate: true } 77 | ) 78 | watch(bound, () => { 79 | const array = Array.from(bound) 80 | if (!isEqual(props[name], array)) { 81 | emit(`update:${name}` as const, array) 82 | } 83 | }) 84 | return Reactive(bound) 85 | } 86 | -------------------------------------------------------------------------------- /src/components/edge/VEdgeLabels.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 77 | 78 | 86 | -------------------------------------------------------------------------------- /src/components/node/VNodeFocusRing.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 74 | 75 | 97 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import fs from "fs/promises" 3 | import { defineConfig } from "vite" 4 | import vue from "@vitejs/plugin-vue" 5 | import dts from "vite-plugin-dts" 6 | import { visualizer } from "rollup-plugin-visualizer"; 7 | 8 | const resolvePath = (str: string) => path.resolve(__dirname, str) 9 | 10 | const TYPES_SRC_DIR = resolvePath("lib/src") 11 | function dtsBeforeWriteFile(filePath: string, _content: string) { 12 | if (filePath.startsWith(TYPES_SRC_DIR)) { 13 | filePath = __dirname + "/lib" + filePath.substring(TYPES_SRC_DIR.length) 14 | } 15 | return { filePath } 16 | } 17 | 18 | // https://vitejs.dev/config/ 19 | export default defineConfig({ 20 | resolve: { 21 | alias: [{ find: "@/", replacement: path.join(__dirname, "./src/") }], 22 | }, 23 | build: { 24 | target: "es2015", 25 | lib: { 26 | formats: ["es"], 27 | entry: [ 28 | resolvePath("src/index.ts"), 29 | resolvePath("src/force-layout.ts"), 30 | ], 31 | name: "v-network-graph", 32 | cssFileName: "style", 33 | }, 34 | emptyOutDir: false, 35 | rollupOptions: { 36 | // make sure to externalize deps that shouldn't be bundled 37 | // into your library 38 | external: ["vue", "lodash-es", "d3-force"], // bundle mitt 39 | output: { 40 | // preserveModules: true, 41 | // preserveModulesRoot: "src", 42 | // entryFileNames: ({ name: fileName }) => { 43 | // return `${fileName}.js`; 44 | // }, 45 | dir: resolvePath("lib"), 46 | }, 47 | }, 48 | cssCodeSplit: false, 49 | sourcemap: true, 50 | }, 51 | css: { 52 | preprocessorOptions: { 53 | scss: { 54 | api: "modern-compiler" 55 | } 56 | } 57 | }, 58 | publicDir: false, 59 | plugins: [ 60 | vue(), 61 | dts({ 62 | outDir: resolvePath("lib"), 63 | staticImport: true, 64 | copyDtsFiles: false, 65 | beforeWriteFile: dtsBeforeWriteFile, 66 | afterBuild: async (emittedFiles) => { 67 | // import * as XXX from "@/..." => relative path 68 | const srcRoot = resolvePath("lib") 69 | const pattern = /from ["']@\/(\w+)/ 70 | for (const [file, sourceContent] of emittedFiles.entries()) { 71 | let matches = pattern.exec(sourceContent) 72 | if (!matches) continue 73 | let content = sourceContent 74 | do { 75 | const topDir = matches[1] 76 | const relativePath = path.relative(path.dirname(file), `${srcRoot}/${topDir}`) 77 | content = content.replace(`@/${topDir}`, relativePath) 78 | matches = pattern.exec(content) 79 | } while(matches) 80 | await fs.writeFile(file, content) 81 | } 82 | } 83 | }), 84 | visualizer({ 85 | filename: "stats-es.html", 86 | gzipSize: true, 87 | brotliSize: true, 88 | }) 89 | ], 90 | }) 91 | -------------------------------------------------------------------------------- /src/composables/svg-pan-zoom.ts: -------------------------------------------------------------------------------- 1 | import { ref, Ref, onMounted, onUnmounted } from "vue" 2 | import { nonNull } from "@/common/common" 3 | import { createSvgPanZoomEx, SvgPanZoomInstance, SvgPanZoomOptions } from "@/modules/svg-pan-zoom-ex" 4 | 5 | type Callback = () => void 6 | 7 | enum State { 8 | INITIAL = 0, 9 | MOUNTED = 1, 10 | UNMOUNTED = 2 11 | } 12 | 13 | export function useSvgPanZoom(svg: Ref, options: SvgPanZoomOptions) { 14 | const instance = ref() 15 | let state = State.INITIAL 16 | const mountedCallbacks: Callback[] = [] 17 | const unmountedCallbacks: Callback[] = [] 18 | 19 | const instanceMounted = () => { 20 | state = State.MOUNTED 21 | mountedCallbacks.forEach(c => c()) 22 | mountedCallbacks.length = 0 // clear 23 | } 24 | 25 | const instanceUnmounted = () => { 26 | state = State.UNMOUNTED 27 | unmountedCallbacks.forEach(c => c()) 28 | unmountedCallbacks.length = 0 // clear 29 | } 30 | 31 | onMounted(() => { 32 | const element = nonNull(svg.value, "") 33 | // hook init/destroy custom events 34 | const userInit = options.customEventsHandler?.init ?? ((_: any) => {}) 35 | const userDestroy = options.customEventsHandler?.destroy ?? ((_: any) => {}) 36 | const haltEventListeners = options.customEventsHandler?.haltEventListeners ?? [] 37 | 38 | options.customEventsHandler = { 39 | init: o => { 40 | instance.value = o.instance 41 | userInit(o) 42 | instanceMounted() 43 | }, 44 | destroy: o => { 45 | instanceUnmounted() 46 | userDestroy(o) 47 | }, 48 | haltEventListeners 49 | } 50 | 51 | const initialize = () => { 52 | const rect = element.getBoundingClientRect() 53 | // In svg-pan-zoom, the shadow viewport is generated based with 54 | // size on initialization. At this time, if the width and height 55 | // are zero, an exception will occur during the calculation. 56 | // Therefore, initialization is performed after allocating the area. 57 | // Note that even after onMounted, the area is not allocated at 58 | // the time of page switching with Nuxt. 59 | if (rect.width !== 0 && rect.height !== 0) { 60 | createSvgPanZoomEx(element, options) 61 | } else { 62 | setTimeout(initialize, 200) 63 | } 64 | } 65 | initialize() 66 | }) 67 | 68 | onUnmounted(() => { 69 | instance.value?.destroy() 70 | instance.value = undefined 71 | }) 72 | 73 | const onSvgPanZoomMounted = (callback: Callback) => { 74 | if (state === State.INITIAL) { 75 | mountedCallbacks.push(callback) 76 | } else if (state === State.MOUNTED) { 77 | callback() 78 | } 79 | } 80 | 81 | const onSvgPanZoomUnmounted = (callback: Callback) => { 82 | if (state === State.INITIAL || state === State.MOUNTED) { 83 | unmountedCallbacks.push(callback) 84 | } else { 85 | callback() 86 | } 87 | } 88 | 89 | return { svgPanZoom: instance, onSvgPanZoomMounted, onSvgPanZoomUnmounted } 90 | } 91 | -------------------------------------------------------------------------------- /src/composables/marker.ts: -------------------------------------------------------------------------------- 1 | import { reactive } from "vue" 2 | import { MarkerStyle } from "@/common/configs" 3 | import { convertToAscii } from "@/utils/string" 4 | 5 | export type MarkerBuilder = (marker: MarkerStyle | null, isSource?: boolean) => string 6 | 7 | export interface HeadMarker extends MarkerStyle { 8 | color: string 9 | isSource: boolean 10 | } 11 | 12 | export interface MarkerState { 13 | markers: Record 14 | referenceCount: Record 15 | } 16 | 17 | export function makeMarkerState(): MarkerState { 18 | const markers: Record = reactive({}) 19 | const referenceCount: Record = {} 20 | return { markers, referenceCount } 21 | } 22 | 23 | export function useMarker(markerState: MarkerState) { 24 | const { markers, referenceCount } = markerState 25 | 26 | function addMarker(key: string, marker: HeadMarker) { 27 | const m = referenceCount[key] ?? 0 28 | referenceCount[key] = m + 1 29 | if (!m) { 30 | markers[key] = marker 31 | } 32 | } 33 | 34 | function removeMarker(key: string) { 35 | const m = referenceCount[key] ?? 0 36 | if (m) { 37 | if (m - 1 === 0) { 38 | delete markers[key] 39 | delete referenceCount[key] 40 | } else { 41 | referenceCount[key] = m - 1 42 | } 43 | } 44 | } 45 | 46 | function clearMarker(id: string | undefined) { 47 | if (id) { 48 | removeMarker(id) 49 | } 50 | } 51 | 52 | function makeMarker( 53 | marker: MarkerStyle, 54 | isSource: boolean, 55 | previousId: string | undefined, 56 | strokeColor: string, 57 | instanceId: number 58 | ) { 59 | if (marker.type === "none") { 60 | clearMarker(previousId) 61 | return undefined 62 | } 63 | 64 | if (marker.type === "custom") { 65 | clearMarker(previousId) 66 | return marker.customId 67 | } 68 | 69 | const headMarker = toHeadMarker(marker, isSource, strokeColor) 70 | const id = buildKey(headMarker, instanceId) 71 | if (id === previousId) { 72 | return id 73 | } 74 | clearMarker(previousId) 75 | addMarker(id, headMarker) 76 | return id 77 | } 78 | 79 | return { 80 | makeMarker, 81 | clearMarker, 82 | } 83 | } 84 | 85 | function toHeadMarker(marker: MarkerStyle, isSource: boolean, strokeColor: string) { 86 | return { 87 | ...marker, 88 | color: marker.color ?? strokeColor, 89 | isSource, 90 | } 91 | } 92 | 93 | function buildKey(m: HeadMarker, instanceId: number) { 94 | // If the same marker ID exists in the previous instance and is hidden by 95 | // `display: none`, the marker in the other instance will disappear. 96 | // For safety, marker IDs will be unique in the entire page. 97 | const c = convertToAscii(m.color) 98 | const d = m.isSource ? "L" : "R" 99 | const u = m.units === "strokeWidth" ? "rel" : "abs" 100 | return `marker_${instanceId}_${m.type}_${m.width}_${m.height}_${m.margin}_${m.offset}_${c}_${d}_${u}` 101 | } 102 | -------------------------------------------------------------------------------- /src/components/node/VNode.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 72 | 73 | 109 | -------------------------------------------------------------------------------- /tests/modules/calculation/curve.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | import { calculateSubpath, parsePathD } from "@/modules/calculation/curve" 3 | 4 | describe("calculateSubpath", () => { 5 | describe("subpath of bezier curve", () => { 6 | const pathD = "M 80 80 Q 120 63 160 63 Q 200 63 240 80" 7 | const segments = parsePathD(pathD) 8 | it("should calculate correctly: applying margin", () => { 9 | let subpath = calculateSubpath(segments, 0, 0) 10 | expect(subpath).equals("M 80 80 Q 120 63 160 63 Q 200 63 240 80") 11 | 12 | subpath = calculateSubpath(segments, 30, 30) 13 | expect(subpath).equals( 14 | "M108.30078125,70.09964948892593C125.53385416666666,65.36654982964197,142.76692708333331,63,160,63C177.23307291666663,63,194.46614583333331,65.36654982964197,211.69921874999997,70.09964948892593" 15 | ) 16 | 17 | subpath = calculateSubpath(segments, 30, 0) 18 | expect(subpath).equals( 19 | "M108.30078125,70.09964948892593C125.53385416666666,65.36654982964197,142.76692708333331,63,160,63C186.66666666666663,63,213.33333333333331,68.66666666666666,240,80" 20 | ) 21 | 22 | subpath = calculateSubpath(segments, 0, 30) 23 | expect(subpath).equals( 24 | "M80,80C106.66666666666666,68.66666666666666,133.33333333333331,63,160,63C177.23307291666663,63,194.46614583333331,65.36654982964197,211.69921874999997,70.09964948892593" 25 | ) 26 | }) 27 | }) 28 | 29 | // describe("subpath of arc", () => { 30 | // const pathD = "M 70 80 A 29 29 0 1 1 70 79"; 31 | // const segments = parsePathD(pathD) 32 | // it("should calculate correctly: applying margin", () => { 33 | 34 | // let subpath = calculateSubpath(segments, 0, 0) 35 | // expect(subpath).equals( 36 | // "M 70 80 A 29 29 0 1 1 70 79" 37 | // ) 38 | 39 | // subpath = calculateSubpath(segments, 30, 30) 40 | // expect(subpath).equals( 41 | // "M55.4181444325443,104.66435316333775C46.8257637070537,109.58274975000052,35.7732842754285,110.18646400408264,26.073453303630487,104.36100356880922C6.9354438332621555,92.86722421289402,7.416569048926579,64.96610914143933,26.93947869182646,54.13899644019079C36.47016922240115,48.85341850213058,47.09500723603882,49.57253869044651,55.40663375181794,54.32911738247944" 42 | // ) 43 | 44 | // subpath = calculateSubpath(segments, 30, 0) 45 | // expect(subpath).equals( 46 | // "M55.4181444325443,104.66435316333775C46.8257637070537,109.58274975000052,35.7732842754285,110.18646400408264,26.073453303630487,104.36100356880922C6.9354438332621555,92.86722421289402,7.416569048926579,64.96610914143933,26.93947869182646,54.13899644019079C46.075610266941126,43.52638505083068,69.62272527612058,57.12131823110912,70,79" 47 | // ) 48 | 49 | // subpath = calculateSubpath(segments, 0, 30) 50 | // expect(subpath).equals( 51 | // "M70,80C69.61509982746846,102.32089205716375,45.21146277399882,115.85478292472442,26.073453303630487,104.36100356880922C6.9354438332621555,92.86722421289402,7.416569048926579,64.96610914143933,26.93947869182646,54.13899644019079C36.47016922240115,48.85341850213058,47.09500723603882,49.57253869044651,55.40663375181794,54.32911738247944" 52 | // ) 53 | // }) 54 | // }) 55 | }) 56 | -------------------------------------------------------------------------------- /src/components/edge/VEdgeSummarized.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 77 | 78 | 85 | -------------------------------------------------------------------------------- /src/utils/svg.ts: -------------------------------------------------------------------------------- 1 | import { Point } from "@/common/types" 2 | import { urlContentToDataUrl } from "./download" 3 | 4 | export interface ExportOptions { 5 | embedImages: boolean 6 | } 7 | 8 | export function translateFromDomToSvgCoordinates( 9 | svg: SVGSVGElement, 10 | viewport: SVGGElement, 11 | coordinates: Point 12 | ): Point { 13 | const point = svg.createSVGPoint() 14 | point.x = coordinates.x 15 | point.y = coordinates.y 16 | const svgPoint = point.matrixTransform(viewport.getCTM()?.inverse()) 17 | return { x: svgPoint.x, y: svgPoint.y } 18 | } 19 | 20 | export function translateFromSvgToDomCoordinates( 21 | svg: SVGSVGElement, 22 | viewport: SVGGElement, 23 | coordinates: Point 24 | ): Point { 25 | const point = svg.createSVGPoint() 26 | point.x = coordinates.x 27 | point.y = coordinates.y 28 | const domPoint = point.matrixTransform(viewport.getCTM() as DOMMatrixInit) 29 | return { x: domPoint.x, y: domPoint.y } 30 | } 31 | 32 | export function exportSvgElement( 33 | element: SVGElement, 34 | svgViewport: SVGGElement, 35 | scale: number 36 | ): SVGElement { 37 | const target = element.cloneNode(true) as SVGElement 38 | 39 | const box = svgViewport.getBBox() 40 | const z = 1 / scale 41 | const svgRect = { 42 | x: Math.floor((box.x - 10) * z), 43 | y: Math.floor((box.y - 10) * z), 44 | width: Math.ceil((box.width + 20) * z), 45 | height: Math.ceil((box.height + 20) * z), 46 | } 47 | target.setAttribute("width", svgRect.width.toString()) 48 | target.setAttribute("height", svgRect.height.toString()) 49 | 50 | const v = target.querySelector(".v-ng-viewport") as SVGGElement 51 | v.setAttribute("transform", `translate(${-svgRect.x} ${-svgRect.y}), scale(${z})`) 52 | v.removeAttribute("style") 53 | 54 | target.setAttribute("viewBox", `0 0 ${svgRect.width} ${svgRect.height}`) 55 | target.removeAttribute("style") 56 | 57 | // remove comments 58 | const iter = document.createNodeIterator(target, NodeFilter.SHOW_COMMENT) 59 | while (iter.nextNode()) { 60 | const commentNode = iter.referenceNode 61 | commentNode.parentNode?.removeChild(commentNode) 62 | } 63 | return target 64 | } 65 | 66 | async function replaceImageSourceToDataUrl(image: SVGImageElement) { 67 | let useNS = false 68 | let href = image.getAttribute("href") 69 | if (!href) { 70 | useNS = true 71 | href = image.getAttribute("xlink:href") 72 | } 73 | if (!href || href.startsWith("data:")) return 74 | 75 | try { 76 | const dataUrl = await urlContentToDataUrl(href) 77 | image.setAttribute(useNS ? "xlink:href" : "href", dataUrl) 78 | } catch (e) { 79 | // output log and ignore 80 | console.warn("Image download failed.", href) 81 | return 82 | } 83 | } 84 | 85 | export async function exportSvgElementWithOptions( 86 | element: SVGElement, 87 | svgViewport: SVGGElement, 88 | scale: number, 89 | options: Partial = {} 90 | ): Promise { 91 | const target = exportSvgElement(element, svgViewport, scale) 92 | 93 | if (options.embedImages) { 94 | // replace image to data-uri 95 | const images = Array.from(target.querySelectorAll("image")) 96 | const promises = images.map(img => replaceImageSourceToDataUrl(img)) 97 | await Promise.all(promises) 98 | } 99 | 100 | return target 101 | } 102 | -------------------------------------------------------------------------------- /src/components/path/VPath.vue: -------------------------------------------------------------------------------- 1 | 86 | 87 | 105 | 106 | 124 | -------------------------------------------------------------------------------- /src/layouts/simple.ts: -------------------------------------------------------------------------------- 1 | import { Ref, toRef, watch } from "vue" 2 | import { isEqual, round } from "lodash-es" 3 | import { NodePositions, OnDragHandler, Position } from "@/common/types" 4 | import { getNodeSize, areNodesCollision } from "@/utils/visual" 5 | import { LayoutActivateParameters, LayoutHandler } from "./handler" 6 | 7 | const NEW_NODE_POSITION_MARGIN = 20 8 | 9 | export class SimpleLayout implements LayoutHandler { 10 | private onDeactivate?: () => void 11 | 12 | activate(parameters: LayoutActivateParameters): void { 13 | const { nodePositions, nodes, configs, emitter, scale, svgPanZoom } = parameters 14 | const onDrag: OnDragHandler = positions => { 15 | for (const [id, pos] of Object.entries(positions)) { 16 | const layout = this.getOrCreateNodePosition(nodePositions, id) 17 | this.setNodePosition(layout, pos) 18 | } 19 | } 20 | 21 | const setNewNodePositions = (nodeIds: string[]) => { 22 | // decide new node's position 23 | const newNodes = nodeIds.filter(n => !(n in nodePositions.value)) 24 | const area = svgPanZoom.getViewArea() 25 | const s = scale.value 26 | for (const nodeId of newNodes) { 27 | const node = nodes.value[nodeId] 28 | const nodeSize = getNodeSize(node, configs.node, s) 29 | const candidate = { ...area.center } 30 | for (;;) { 31 | let collision = false 32 | for (const [id, pos] of Object.entries(nodePositions.value)) { 33 | if (nodeId === id) continue 34 | const targetNode = nodes.value[id] 35 | if (!targetNode) continue 36 | const targetNodeSize = getNodeSize(targetNode, configs.node, s) 37 | collision = areNodesCollision(candidate, nodeSize, pos, targetNodeSize) 38 | if (collision) { 39 | break 40 | } 41 | } 42 | if (collision) { 43 | // Slide the width of one node + margin in the horizontal direction. 44 | // If it reaches the edge of the display area, it moves downward. 45 | candidate.x += nodeSize.width + NEW_NODE_POSITION_MARGIN * s 46 | if (candidate.x + nodeSize.width / 2 > area.box.right) { 47 | candidate.x = area.center.x 48 | candidate.y += nodeSize.height + NEW_NODE_POSITION_MARGIN * s 49 | } 50 | } else { 51 | break 52 | } 53 | } 54 | const layout = this.getOrCreateNodePosition(nodePositions, nodeId) 55 | this.setNodePosition(layout, candidate) 56 | } 57 | } 58 | 59 | setNewNodePositions(Object.keys(nodes.value)) 60 | const stopNodeWatch = watch( 61 | () => isEqual(new Set(Object.keys(nodes.value)), new Set(Object.keys(nodePositions.value))), 62 | (equality: boolean) => { 63 | if (!equality) setNewNodePositions(Object.keys(nodes.value)) 64 | } 65 | ) 66 | 67 | emitter.on("node:dragstart", onDrag) 68 | emitter.on("node:pointermove", onDrag) 69 | emitter.on("node:dragend", onDrag) 70 | 71 | this.onDeactivate = () => { 72 | stopNodeWatch() 73 | emitter.off("node:dragstart", onDrag) 74 | emitter.off("node:pointermove", onDrag) 75 | emitter.off("node:dragend", onDrag) 76 | } 77 | } 78 | 79 | deactivate(): void { 80 | if (this.onDeactivate) { 81 | this.onDeactivate() 82 | } 83 | } 84 | 85 | protected setNodePosition(nodeLayout: Ref, pos: Position) { 86 | nodeLayout.value.x = round(pos.x, 3) 87 | nodeLayout.value.y = round(pos.y, 3) 88 | } 89 | 90 | private getOrCreateNodePosition(nodePositions: Ref, node: string) { 91 | const layout = toRef(nodePositions.value, node) 92 | if (!layout.value) { 93 | layout.value = { x: 0, y: 0 } 94 | } 95 | return layout 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/modules/node/label.ts: -------------------------------------------------------------------------------- 1 | import { NodeLabelDirection, NodeLabelDirectionType, OppositeNodes } from "@/common/configs" 2 | import { Position } from "@/common/types" 3 | import { subtract, angleDegree } from "@/modules/vector2d" 4 | 5 | const labelDirections: Record = { 6 | [NodeLabelDirection.NORTH]: 0, 7 | [NodeLabelDirection.NORTH_EAST]: 1, 8 | [NodeLabelDirection.EAST]: 2, 9 | [NodeLabelDirection.SOUTH_EAST]: 3, 10 | [NodeLabelDirection.SOUTH]: 4, 11 | [NodeLabelDirection.SOUTH_WEST]: 5, 12 | [NodeLabelDirection.WEST]: 6, 13 | [NodeLabelDirection.NORTH_WEST]: 7, 14 | [NodeLabelDirection.CENTER]: -1, 15 | } 16 | 17 | const denyAngles: ((a: number, loop: boolean) => boolean)[] = [ 18 | /* N */ (a, loop) => inRange(a, 0, loop ? 90 : 60), 19 | /* NE */ (a, loop) => inRange(a, 45, loop ? 90 : 45), 20 | /* E */ (a, loop) => inRange(a, 90, loop ? 60 : 30), 21 | /* SE */ (a, loop) => inRange(a, 135, loop ? 90 : 45), 22 | /* S */ (a, loop) => inRange(a, 180, loop ? 90 : 60), 23 | /* SW */ (a, loop) => inRange(a, 225, loop ? 90 : 45), 24 | /* W */ (a, loop) => inRange(a, 270, loop ? 60 : 30), 25 | /* NW */ (a, loop) => inRange(a, 315, loop ? 90 : 45), 26 | ] 27 | 28 | export function handleNodeLabelAutoAdjustment( 29 | nodeId: string, 30 | currentPos: Position, 31 | oppositeNodes: OppositeNodes, 32 | getLoopCenter: (edgeId: string) => Position | undefined, 33 | defaultDirection: NodeLabelDirectionType 34 | ): NodeLabelDirectionType { 35 | if (defaultDirection === NodeLabelDirection.CENTER) { 36 | return NodeLabelDirection.CENTER 37 | } 38 | 39 | // Avoid overlapping edges from the node. 40 | const angles: [number, boolean][] = [] 41 | Object.entries(oppositeNodes).forEach(([edgeId, oppositeNode]) => { 42 | let isSelfLoop = false 43 | if (oppositeNode.nodeId === nodeId) { 44 | // self looped edge 45 | const center = getLoopCenter(edgeId) 46 | if (center) { 47 | isSelfLoop = true 48 | oppositeNode = { 49 | ...oppositeNode, 50 | pos: { x: center.x, y: center.y }, 51 | } 52 | } 53 | } 54 | // angleDegree(): east=0, north=90, west=180, south=-90 55 | // -> Divide into 10 azimuths except horizontal, and assign indexes including 56 | // horizontal in clockwise direction 57 | const angle = (angleDegree(subtract(oppositeNode.pos, currentPos)) + 360 + 90) % 360 58 | angles.push([angle, isSelfLoop]) 59 | }) 60 | 61 | const directionIndex = directionToIndex(defaultDirection) 62 | 63 | // order of priority. 64 | const candidates = [ 65 | directionIndex, 66 | (directionIndex + 4) % 8, // priority is given to diagonals 67 | (directionIndex + 2) % 8, 68 | (directionIndex - 2 + 8) % 8, 69 | (directionIndex + 1) % 8, 70 | (directionIndex - 1 + 8) % 8, 71 | (directionIndex + 3) % 8, 72 | (directionIndex - 3 + 8) % 8, 73 | ] 74 | 75 | const azimuth = candidates.find(c => { 76 | return angles.every(a => !denyAngles[c](...a)) 77 | }) 78 | if (azimuth === undefined) { 79 | return defaultDirection 80 | } else { 81 | return indexToDirection(azimuth, defaultDirection) 82 | } 83 | } 84 | 85 | function inRange(target: number, center: number, amount: number): boolean { 86 | target %= 360 87 | const min = (center - amount + 360) % 360 88 | const max = (center + amount) % 360 89 | if (min <= max) { 90 | return min < target && target < max 91 | } else { 92 | return min < target || target < max 93 | } 94 | } 95 | 96 | function directionToIndex(direction: NodeLabelDirectionType) { 97 | return labelDirections[direction] ?? 0 98 | } 99 | 100 | function indexToDirection(index: number, defaultValue: NodeLabelDirectionType) { 101 | return (Object.entries(labelDirections)[index]?.[0] ?? defaultValue) as NodeLabelDirectionType 102 | } 103 | -------------------------------------------------------------------------------- /src/modules/calculation/line.ts: -------------------------------------------------------------------------------- 1 | import { LinePosition, Position } from "@/common/types" 2 | import { Vector2D } from "@/modules/vector2d" 3 | 4 | // --------------------------- 5 | // Line information by vectors 6 | // --------------------------- 7 | 8 | export class VectorLine { 9 | public source: Vector2D 10 | public target: Vector2D 11 | public v: Vector2D 12 | 13 | constructor(source: Vector2D, target: Vector2D, v: Vector2D) { 14 | this.source = source 15 | this.target = target 16 | this.v = v 17 | } 18 | 19 | static fromLinePosition(line: LinePosition): VectorLine { 20 | const source = Vector2D.fromObject(line.p1) 21 | const target = Vector2D.fromObject(line.p2) 22 | return new VectorLine(source, target, toLineVector(source, target)) 23 | } 24 | 25 | static fromPositions(sourcePos: Position, targetPos: Position): VectorLine { 26 | const source = Vector2D.fromObject(sourcePos) 27 | const target = Vector2D.fromObject(targetPos) 28 | return new VectorLine(source, target, toLineVector(source, target)) 29 | } 30 | 31 | static fromVectors(source: Vector2D, target: Vector2D): VectorLine { 32 | return new VectorLine(source, target, toLineVector(source, target)) 33 | } 34 | } 35 | 36 | export function toLineVector(source: Vector2D, target: Vector2D): Vector2D { 37 | return target.clone().subtract(source) 38 | } 39 | 40 | export function toVectorsFromLinePosition(line: LinePosition): [Vector2D, Vector2D] { 41 | return [Vector2D.fromObject(line.p1), Vector2D.fromObject(line.p2)] 42 | } 43 | 44 | export function getCenterOfLinePosition(line: LinePosition): Vector2D { 45 | return new Vector2D((line.p1.x + line.p2.x) / 2, (line.p1.y + line.p2.y) / 2) 46 | } 47 | 48 | // ------------------------------- 49 | // Calculation functions for Lines 50 | // ------------------------------- 51 | 52 | /** 53 | * Convert two `Position` to `LinePosition` 54 | * @param p1 source position of the line 55 | * @param p2 target position of the line 56 | * @returns `LinePosition` instance 57 | */ 58 | export function toLinePosition(p1: Position, p2: Position): LinePosition { 59 | return { p1, p2 } 60 | } 61 | 62 | /** 63 | * Calculates the line position to which the margin is applied. 64 | * @param linePos original position of the line 65 | * @param sourceMargin margin for source side 66 | * @param targetMargin margin for target side 67 | * @returns the line position 68 | */ 69 | export function applyMargin( 70 | linePos: LinePosition, 71 | sourceMargin: number, 72 | targetMargin: number 73 | ): LinePosition { 74 | const line = VectorLine.fromLinePosition(linePos) 75 | return applyMarginInner(line, sourceMargin, targetMargin) 76 | } 77 | 78 | function applyMarginInner( 79 | line: VectorLine, 80 | sourceMargin: number, 81 | targetMargin: number 82 | ): LinePosition { 83 | const normalized = line.v.clone().normalize() 84 | 85 | const sv = line.source.clone().add(normalized.clone().multiplyScalar(sourceMargin)) 86 | 87 | const tv = line.target.clone().subtract(normalized.clone().multiplyScalar(targetMargin)) 88 | 89 | let p1 = sv.toObject() 90 | let p2 = tv.toObject() 91 | 92 | const check = toLineVector(sv, tv) 93 | if (line.v.angle() * check.angle() < 0) { 94 | // reversed 95 | const c1 = new Vector2D((p1.x + p2.x) / 2, (p1.y + p2.y) / 2) 96 | const c2 = c1.clone().add(normalized.multiplyScalar(0.5)) 97 | p1 = c1.toObject() 98 | p2 = c2.toObject() 99 | } 100 | 101 | return { p1, p2 } 102 | } 103 | 104 | export function inverseLine(line: LinePosition): LinePosition { 105 | return { p1: line.p2, p2: line.p1 } 106 | } 107 | 108 | export function calculatePerpendicularLine(line: VectorLine) { 109 | const n1 = line.v 110 | .clone() 111 | .normalize() 112 | .rotate(Math.PI / 2) 113 | return VectorLine.fromVectors(line.target, line.target.clone().add(n1)) 114 | } 115 | -------------------------------------------------------------------------------- /tests/utils/box.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | import { Box, ViewBox } from "@/common/types" 3 | import { 4 | areBoxesSame, 5 | boxAdd, 6 | boxDivide, 7 | boxMultiply, 8 | boxToViewBox, 9 | mergeBox, 10 | viewBoxToBox, 11 | } from "@/utils/box" 12 | 13 | describe("box", () => { 14 | describe("areBoxesSame", () => { 15 | it("should return true for the same box", () => { 16 | const box1: ViewBox = { x: 10, y: 20, width: 100, height: 200 } 17 | const box2: ViewBox = { x: 10, y: 20, width: 100, height: 200 } 18 | const actual = areBoxesSame(box1, box2) 19 | expect(actual).toBeTruthy() 20 | }) 21 | 22 | it("should return false in case of different box", () => { 23 | const box1: ViewBox = { x: 10, y: 20, width: 100, height: 200 } 24 | const box2: ViewBox = { x: 11, y: 20, width: 100, height: 200 } 25 | const actual = areBoxesSame(box1, box2) 26 | expect(actual).toBeFalsy() 27 | }) 28 | }) 29 | 30 | describe("boxAdd", () => { 31 | it("should be all fields added", () => { 32 | const box1: Box = { 33 | top: 1, 34 | left: 2, 35 | right: 3, 36 | bottom: 4, 37 | } 38 | const box2: Box = { 39 | top: 5, 40 | left: 6, 41 | right: 7, 42 | bottom: 8, 43 | } 44 | const actual = boxAdd(box1, box2) 45 | expect(actual).toStrictEqual({ 46 | top: 6, 47 | left: 8, 48 | right: 10, 49 | bottom: 12, 50 | }) 51 | }) 52 | }) 53 | 54 | describe("boxMultiply", () => { 55 | it("should be all fields multiplied", () => { 56 | const box: Box = { 57 | top: 1, 58 | left: 2, 59 | right: 3, 60 | bottom: 4, 61 | } 62 | const actual = boxMultiply(box, 4) 63 | expect(actual).toStrictEqual({ 64 | top: 4, 65 | left: 8, 66 | right: 12, 67 | bottom: 16, 68 | }) 69 | }) 70 | }) 71 | 72 | describe("boxDivide", () => { 73 | it("should be all fields divided", () => { 74 | const box: Box = { 75 | top: 4, 76 | left: 8, 77 | right: 12, 78 | bottom: 16, 79 | } 80 | const actual = boxDivide(box, 4) 81 | expect(actual).toStrictEqual({ 82 | top: 1, 83 | left: 2, 84 | right: 3, 85 | bottom: 4, 86 | }) 87 | }) 88 | }) 89 | 90 | describe("viewBoxToBox", () => { 91 | it("should be converted from Box to ViewBox", () => { 92 | const viewBox: ViewBox = { 93 | x: 10, 94 | y: 20, 95 | width: 100, 96 | height: 200, 97 | } 98 | const actual = viewBoxToBox(viewBox) 99 | expect(actual).toStrictEqual({ 100 | top: 20, 101 | left: 10, 102 | right: 110, 103 | bottom: 220, 104 | }) 105 | }) 106 | }) 107 | 108 | describe("boxToViewBox", () => { 109 | it("should be converted from ViewBox to Box", () => { 110 | const box: Box = { 111 | top: 20, 112 | left: 10, 113 | right: 110, 114 | bottom: 220, 115 | } 116 | const actual = boxToViewBox(box) 117 | expect(actual).toStrictEqual({ 118 | x: 10, 119 | y: 20, 120 | width: 100, 121 | height: 200, 122 | }) 123 | }) 124 | }) 125 | 126 | describe("mergeBox", () => { 127 | it("should generate a box merged boxes", () => { 128 | const box1: Box = { 129 | top: 10, 130 | left: 20, 131 | right: 50, 132 | bottom: 60, 133 | } 134 | const box2: Box = { 135 | top: 20, 136 | left: 30, 137 | right: 60, 138 | bottom: 70, 139 | } 140 | const actual = mergeBox(box1, box2) 141 | expect(actual).toStrictEqual({ 142 | top: 10, 143 | left: 20, 144 | right: 60, 145 | bottom: 70, 146 | }) 147 | }) 148 | }) 149 | }) 150 | -------------------------------------------------------------------------------- /src/modules/vector2d/methods.ts: -------------------------------------------------------------------------------- 1 | import { Point2D } from "./core" 2 | 3 | export function add(v1: Point2D, v2: Point2D): Point2D 4 | export function add(v1: Point2D, v2: Point2D, target: T): T 5 | export function add(v1: Point2D, v2: Point2D, target?: Point2D): Point2D { 6 | if (!target) { 7 | target = { x: 0, y: 0 } 8 | } 9 | target.x = v1.x + v2.x 10 | target.y = v1.y + v2.y 11 | return target 12 | } 13 | 14 | export function subtract(v1: Point2D, v2: Point2D): Point2D 15 | export function subtract(v1: Point2D, v2: Point2D, target: T): T 16 | export function subtract(v1: Point2D, v2: Point2D, target?: Point2D): Point2D { 17 | if (!target) { 18 | target = { x: 0, y: 0 } 19 | } 20 | target.x = v1.x - v2.x 21 | target.y = v1.y - v2.y 22 | return target 23 | } 24 | 25 | export function multiply(v1: Point2D, v2: Point2D): Point2D 26 | export function multiply(v1: Point2D, v2: Point2D, target: T): T 27 | export function multiply(v1: Point2D, v2: Point2D, target?: Point2D): Point2D { 28 | if (!target) { 29 | target = { x: 0, y: 0 } 30 | } 31 | target.x = v1.x * v2.x 32 | target.y = v1.y * v2.y 33 | return target 34 | } 35 | 36 | export function multiplyScalar(v: Point2D, scalar: number): Point2D 37 | export function multiplyScalar(v: Point2D, scalar: number, target: T): T 38 | export function multiplyScalar(v: Point2D, scalar: number, target?: Point2D): Point2D { 39 | if (!target) { 40 | target = { x: 0, y: 0 } 41 | } 42 | target.x = v.x * scalar 43 | target.y = v.y * scalar 44 | return target 45 | } 46 | 47 | export function divide(v1: Point2D, v2: Point2D): Point2D 48 | export function divide(v1: Point2D, v2: Point2D, target: T): T 49 | export function divide(v1: Point2D, v2: Point2D, target?: Point2D): Point2D { 50 | if (!target) { 51 | target = { x: 0, y: 0 } 52 | } 53 | target.x = v1.x / v2.x 54 | target.y = v1.y / v2.y 55 | return target 56 | } 57 | 58 | export function dot(v1: Point2D, v2: Point2D): number { 59 | return v1.x * v2.x + v1.y * v2.y 60 | } 61 | 62 | export function cross(v1: Point2D, v2: Point2D): number { 63 | return v1.x * v2.y - v1.y * v2.x 64 | } 65 | 66 | export function lengthSquared(v: Point2D): number { 67 | return v.x * v.x + v.y * v.y 68 | } 69 | 70 | export function length(v: Point2D): number { 71 | return Math.sqrt(lengthSquared(v)) 72 | } 73 | 74 | export function distanceSquared(v1: Point2D, v2: Point2D): number { 75 | const dx = v1.x - v2.x 76 | const dy = v1.y - v2.y 77 | return dx * dx + dy * dy 78 | } 79 | 80 | export function distance(v1: Point2D, v2: Point2D): number { 81 | return Math.sqrt(distanceSquared(v1, v2)) 82 | } 83 | 84 | export function normalize(v: Point2D): Point2D 85 | export function normalize(v: Point2D, target: T): T 86 | export function normalize(v: Point2D, target?: Point2D): Point2D { 87 | if (!target) { 88 | target = { x: 0, y: 0 } 89 | } 90 | const len = length(v) 91 | if (len === 0) { 92 | target.x = 1 93 | target.y = 0 94 | } else { 95 | divide(v, { x: len, y: len }, target) 96 | } 97 | return target 98 | } 99 | 100 | export function rotate(v: Point2D, angle: number): Point2D 101 | export function rotate(v: Point2D, angle: number, target: T): T 102 | export function rotate(v: Point2D, angle: number, target?: Point2D): Point2D { 103 | if (!target) { 104 | target = { x: 0, y: 0 } 105 | } 106 | // rotate in radians CCW from +X axis 107 | const newX = v.x * Math.cos(angle) - v.y * Math.sin(angle) 108 | const newY = v.x * Math.sin(angle) + v.y * Math.cos(angle) 109 | target.x = newX 110 | target.y = newY 111 | return target 112 | } 113 | 114 | const DEGREES = 180 / Math.PI 115 | 116 | function rad2deg(rad: number) { 117 | return rad * DEGREES 118 | } 119 | 120 | export function angle(v: Point2D) { 121 | return Math.atan2(v.y, v.x) 122 | } 123 | 124 | export function angleDegree(v: Point2D) { 125 | return rad2deg(angle(v)) 126 | } 127 | -------------------------------------------------------------------------------- /src/components/edge/VEdgeOverlay.vue: -------------------------------------------------------------------------------- 1 | 103 | 104 | 136 | -------------------------------------------------------------------------------- /tests/composables/layer.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | import { Slots } from "vue" 3 | import { useBuiltInLayerOrder } from "@/composables/layer" 4 | import { getFullConfigs } from "@/common/config-defaults" 5 | import { LayerName } from "@/common/types" 6 | 7 | function getConfigs() { 8 | const configs = getFullConfigs({ 9 | view: { 10 | builtInLayerOrder: [], 11 | }, 12 | node: { 13 | label: { visible: true }, 14 | focusring: { visible: true }, 15 | }, 16 | path: { visible: true }, 17 | }) 18 | return configs 19 | } 20 | 21 | describe("useBuiltInLayerOrder", () => { 22 | const slots = { 23 | "edge-label": {}, 24 | "edges-label": {}, 25 | } as unknown as Slots 26 | 27 | describe("Empty is specified", () => { 28 | it("should be returned the default order", () => { 29 | const configs = getConfigs() 30 | configs.view.builtInLayerOrder = [] // empty 31 | 32 | const layers = useBuiltInLayerOrder(configs, slots) 33 | 34 | const defaultOrder = ["edges", "edge-labels", "focusring", "nodes", "node-labels", "paths"] 35 | expect(defaultOrder).to.be.eql(layers.value) 36 | }) 37 | }) 38 | 39 | describe("The order of all layers is specified", () => { 40 | it("should be returned the same as parameter", () => { 41 | const configs = getConfigs() 42 | const input: LayerName[] = [ 43 | "paths", 44 | "edge-labels", 45 | "edges", 46 | "nodes", 47 | "focusring", 48 | "node-labels", 49 | ] 50 | configs.view.builtInLayerOrder = input 51 | 52 | const layers = useBuiltInLayerOrder(configs, slots) 53 | 54 | const reversed = [...input].reverse() 55 | expect(reversed).to.be.eql(layers.value) 56 | }) 57 | }) 58 | 59 | describe("Partial layers are specified", () => { 60 | it("should not change position of the specified lowest layer", () => { 61 | const configs = getConfigs() 62 | configs.view.builtInLayerOrder = ["node-labels", "paths"] 63 | 64 | const layers = useBuiltInLayerOrder(configs, slots) 65 | 66 | const order = ["edges", "edge-labels", "focusring", "nodes", "paths", "node-labels"] 67 | expect(order).to.be.eql(layers.value) 68 | }) 69 | }) 70 | 71 | describe("Not specified edge-label/edges-label slot", () => { 72 | it("should contain edge-labels layer", () => { 73 | const slots = { 74 | // "edge-label": {}, 75 | "edges-label": {}, 76 | } as unknown as Slots 77 | const configs = getConfigs() 78 | const layers = useBuiltInLayerOrder(configs, slots) 79 | expect(layers.value.includes("edge-labels")).toBe(true) 80 | }) 81 | 82 | it("should contain edge-labels layer", () => { 83 | const slots = { 84 | "edge-label": {}, 85 | // "edges-label": {}, 86 | } as unknown as Slots 87 | const configs = getConfigs() 88 | const layers = useBuiltInLayerOrder(configs, slots) 89 | expect(layers.value.includes("edge-labels")).toBe(true) 90 | }) 91 | 92 | it("should not contain edge-labels layer", () => { 93 | const slots = {} as unknown as Slots 94 | const configs = getConfigs() 95 | const layers = useBuiltInLayerOrder(configs, slots) 96 | expect(layers.value.includes("edge-labels")).toBe(false) 97 | }) 98 | }) 99 | 100 | describe("Configured the focusring invisible", () => { 101 | it("should not contain focusring layer", () => { 102 | const configs = getConfigs() 103 | configs.node.focusring.visible = false 104 | const layers = useBuiltInLayerOrder(configs, slots) 105 | expect(layers.value.includes("focusring")).toBe(false) 106 | }) 107 | }) 108 | 109 | describe("Configured the node labels invisible", () => { 110 | it("should not contain node-labels layer", () => { 111 | const configs = getConfigs() 112 | configs.node.label.visible = false 113 | const layers = useBuiltInLayerOrder(configs, slots) 114 | expect(layers.value.includes("node-labels")).toBe(false) 115 | }) 116 | }) 117 | 118 | describe("Configured the path invisible", () => { 119 | it("should not contain paths layer", () => { 120 | const configs = getConfigs() 121 | configs.path.visible = false 122 | const layers = useBuiltInLayerOrder(configs, slots) 123 | expect(layers.value.includes("paths")).toBe(false) 124 | }) 125 | }) 126 | }) 127 | -------------------------------------------------------------------------------- /tests/modules/calculation/2d.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | import { CircleShapeStyle, RectangleShapeStyle } from "@/common/configs" 3 | import * as v2d from "@/modules/calculation/2d" 4 | 5 | describe("calculateDistancesFromCenterOfNodeToEndOfNode", () => { 6 | describe("circle to circle", () => { 7 | const sourceShape: CircleShapeStyle = { 8 | type: "circle", 9 | radius: 10, 10 | strokeWidth: 0, 11 | color: "#fff", 12 | } 13 | const targetShape: CircleShapeStyle = { 14 | type: "circle", 15 | radius: 15, 16 | strokeWidth: 2, 17 | color: "#fff", 18 | } 19 | 20 | it("should radius with half of stroke width", () => { 21 | const sourcePos = { x: 10, y: 10 } 22 | const targetPos = { x: 110, y: 110 } 23 | 24 | const [sourceMargin, targetMargin] = v2d.calculateDistancesFromCenterOfNodeToEndOfNode( 25 | sourcePos, 26 | targetPos, 27 | sourceShape, 28 | targetShape 29 | ) 30 | 31 | expect(sourceMargin).to.be.equal(10) 32 | expect(targetMargin).to.be.equal(16) 33 | }) 34 | }) 35 | 36 | describe("rect to rect", () => { 37 | const sourceShape: RectangleShapeStyle = { 38 | type: "rect", 39 | width: 100, 40 | height: 20, 41 | borderRadius: 0, 42 | strokeWidth: 2, 43 | color: "#fff", 44 | } 45 | const targetShape: RectangleShapeStyle = { 46 | type: "rect", 47 | width: 40, 48 | height: 30, 49 | borderRadius: 0, 50 | strokeWidth: 4, 51 | color: "#fff", 52 | } 53 | 54 | it("should calculate correctly: vertically aligned", () => { 55 | const sourcePos = { x: 10, y: 10 } 56 | const targetPos = { x: 10, y: 110 } 57 | 58 | const [sourceMargin, targetMargin] = v2d.calculateDistancesFromCenterOfNodeToEndOfNode( 59 | sourcePos, 60 | targetPos, 61 | sourceShape, 62 | targetShape 63 | ) 64 | 65 | expect(sourceMargin).to.be.equal(11) // half of height + stroke 66 | expect(targetMargin).to.be.equal(17) 67 | }) 68 | 69 | it("should calculate correctly: horizontally aligned", () => { 70 | const sourcePos = { x: 10, y: 10 } 71 | const targetPos = { x: 210, y: 10 } 72 | 73 | const [sourceMargin, targetMargin] = v2d.calculateDistancesFromCenterOfNodeToEndOfNode( 74 | sourcePos, 75 | targetPos, 76 | sourceShape, 77 | targetShape 78 | ) 79 | 80 | expect(sourceMargin).to.be.equal(51) // half of width + stroke 81 | expect(targetMargin).to.be.equal(22) 82 | }) 83 | 84 | it("should calculate correctly: located at an angle", () => { 85 | const sourcePos = { x: 10, y: 10 } 86 | const targetPos = { x: 60, y: 60 } 87 | 88 | const [sourceMargin, targetMargin] = v2d.calculateDistancesFromCenterOfNodeToEndOfNode( 89 | sourcePos, 90 | targetPos, 91 | sourceShape, 92 | targetShape 93 | ) 94 | 95 | expect(sourceMargin).toBeCloseTo(Math.sqrt(11 ** 2 + 11 ** 2)) 96 | expect(targetMargin).toBeCloseTo(Math.sqrt(17 ** 2 + 17 ** 2)) 97 | }) 98 | }) 99 | 100 | describe("rounded rect to rounded rect", () => { 101 | const sourceShape: RectangleShapeStyle = { 102 | type: "rect", 103 | width: 40, 104 | height: 40, 105 | borderRadius: 10, 106 | strokeWidth: 2, 107 | color: "#fff", 108 | } 109 | const targetShape: RectangleShapeStyle = { 110 | type: "rect", 111 | width: 40, 112 | height: 40, 113 | borderRadius: 20, 114 | strokeWidth: 4, 115 | color: "#fff", 116 | } 117 | 118 | it("should calculate correctly: located at an angle 1", () => { 119 | const sourcePos = { x: 10, y: 10 } 120 | const targetPos = { x: 100, y: 100 } 121 | 122 | const [sourceMargin, targetMargin] = v2d.calculateDistancesFromCenterOfNodeToEndOfNode( 123 | sourcePos, 124 | targetPos, 125 | sourceShape, 126 | targetShape 127 | ) 128 | 129 | expect(sourceMargin).toBeCloseTo(25.142) 130 | expect(targetMargin).toBeCloseTo(22) 131 | }) 132 | 133 | it("should calculate correctly: located at an angle 2", () => { 134 | const sourcePos = { x: 10, y: 10 } 135 | const targetPos = { x: 140, y: 100 } 136 | 137 | const [sourceMargin, targetMargin] = v2d.calculateDistancesFromCenterOfNodeToEndOfNode( 138 | sourcePos, 139 | targetPos, 140 | sourceShape, 141 | targetShape 142 | ) 143 | 144 | expect(sourceMargin).toBeCloseTo(24.619) 145 | expect(targetMargin).toBeCloseTo(22) 146 | }) 147 | }) 148 | }) 149 | -------------------------------------------------------------------------------- /src/composables/mouse/core.ts: -------------------------------------------------------------------------------- 1 | import { Ref } from "vue" 2 | import { Position, ViewMode } from "@/common/types" 3 | 4 | const MOUSE_MOVE_DETECTION_THRESHOLD = 3 // Sensitivity to start dragging 5 | const TOUCH_MOVE_DETECTION_THRESHOLD = 6 // Sensitivity to start dragging in touches 6 | export const DOUBLE_CLICK_THRESHOLD = 500 7 | 8 | export type SelectionMode = "container" | "node" | "edge" | "path" 9 | 10 | // state for each pointer of multi touch 11 | export interface NodePointerState { 12 | pointerId: number // pointer ID provided by the event 13 | nodeId: string // pointer down node ID 14 | moveCounter: number // count for pointermove event occurred 15 | dragBasePosition: Position // drag started position 16 | nodeBasePosition: Position // node position at drag started 17 | latestPosition: Position // latest position 18 | eventTarget: EventTarget | null // event target 19 | } 20 | 21 | export interface EdgePointerState { 22 | pointerId: number // pointer ID provided by the event 23 | id: string | string[] // pointer down edge ID 24 | eventTarget: EventTarget | null // event target 25 | } 26 | 27 | export interface PathPointerState { 28 | pointerId: number // pointer ID provided by the event 29 | id: string // pointer down path ID 30 | eventTarget: EventTarget | null // event target 31 | } 32 | 33 | export interface ClickState { 34 | lastTime: number 35 | count: number 36 | id: string // clicked object ID 37 | } 38 | 39 | export interface InteractionModes { 40 | selectionMode: Ref 41 | viewMode: Ref 42 | } 43 | 44 | export function getPointerMoveDetectionThreshold(type: string): number { 45 | return type === "touch" ? TOUCH_MOVE_DETECTION_THRESHOLD : MOUSE_MOVE_DETECTION_THRESHOLD 46 | } 47 | 48 | export function detectClicks( 49 | clickStates: Map, 50 | pointerId: number, 51 | id: string, 52 | event: MouseEvent, 53 | ): [MouseEvent, MouseEvent | undefined] { 54 | // search click states 55 | let clickState = clickStates.get(pointerId) 56 | if (clickState) { 57 | if (clickState.id !== id) { 58 | // click an other object 59 | clickState = undefined 60 | } 61 | } else { 62 | const idAndState = Array.from(clickStates.entries()).find(([_, state]) => state.id === id) 63 | if (idAndState) { 64 | const [oldPointerId, state] = idAndState 65 | clickStates.delete(oldPointerId) 66 | clickState = state 67 | } 68 | } 69 | 70 | let clickEvent: MouseEvent, doubleClickEvent: MouseEvent | undefined 71 | [clickState, clickEvent, doubleClickEvent] = createClickEvents(clickState, event, id) 72 | 73 | // update 74 | clickStates.set(pointerId, clickState) 75 | 76 | return [ clickEvent, doubleClickEvent ] 77 | } 78 | 79 | export function createClickEvents( 80 | clickState: ClickState | undefined, 81 | event: MouseEvent, 82 | id: string 83 | ): [ClickState, MouseEvent, MouseEvent | undefined] { 84 | const now = Date.now() 85 | if (clickState && now - clickState.lastTime <= DOUBLE_CLICK_THRESHOLD) { 86 | // continuous clicked 87 | clickState.count++ 88 | clickState.lastTime = now 89 | } else { 90 | // single clicked 91 | clickState = { count: 1, lastTime: now, id } 92 | } 93 | 94 | const initDict = { 95 | view: window, 96 | screenX: event.screenX, 97 | screenY: event.screenY, 98 | clientX: event.clientX, 99 | clientY: event.clientY, 100 | ctrlKey: event.ctrlKey, 101 | shiftKey: event.shiftKey, 102 | altKey: event.altKey, 103 | metaKey: event.metaKey, 104 | button: event.button, 105 | buttons: event.buttons, 106 | detail: clickState.count, 107 | } 108 | 109 | let clickEvent: MouseEvent 110 | let doubleClickEvent: MouseEvent | undefined = undefined 111 | if (event instanceof PointerEvent) { 112 | Object.assign(initDict, { 113 | pointerId: event.pointerId, 114 | width: event.width, 115 | height: event.height, 116 | pressure: event.pressure, 117 | tangentialPressure: event.tangentialPressure, 118 | tiltX: event.tiltX, 119 | tiltY: event.tiltY, 120 | twist: event.twist, 121 | pointerType: event.pointerType, 122 | isPrimary: event.isPrimary, 123 | }) 124 | clickEvent = new PointerEvent("click", initDict) 125 | if (clickState.count === 2) { 126 | doubleClickEvent = new PointerEvent("dblclick", initDict) 127 | } 128 | } else { 129 | clickEvent = new MouseEvent("click", initDict) 130 | if (clickState.count === 2) { 131 | doubleClickEvent = new MouseEvent("dblclick", initDict) 132 | } 133 | } 134 | 135 | return [clickState, clickEvent, doubleClickEvent] 136 | } 137 | 138 | export function cleanClickState(states: Map) { 139 | const now = Date.now() 140 | Array.from(states.entries()) 141 | .filter(([_, state]) => now - state.lastTime > DOUBLE_CLICK_THRESHOLD) 142 | .map(([pointerId, _]) => states.delete(pointerId)) 143 | } 144 | -------------------------------------------------------------------------------- /src/components/edge/VEdgeLabel.vue: -------------------------------------------------------------------------------- 1 | 130 | 131 | 169 | -------------------------------------------------------------------------------- /src/composables/mouse/container.ts: -------------------------------------------------------------------------------- 1 | import { onMounted, onUnmounted, Ref, watch } from "vue" 2 | import { Emitter } from "mitt" 3 | import { Events } from "@/common/types" 4 | import { entriesOf } from "@/utils/object" 5 | import { 6 | ClickState, 7 | createClickEvents, 8 | getPointerMoveDetectionThreshold, 9 | InteractionModes, 10 | } from "./core" 11 | 12 | export function setupContainerInteractionHandlers( 13 | container: Ref, 14 | modes: InteractionModes, 15 | isSvgWheelZoomEnabled: Ref, 16 | emitter: Emitter 17 | ) { 18 | const state = { 19 | moveCounter: 0, 20 | pointerCounter: 0, 21 | clickState: undefined as ClickState | undefined, 22 | } 23 | 24 | // measure the number of move events in the pointerdown state 25 | // and use it to determine the click when pointerup. 26 | const containerPointerHandlers = { 27 | pointermove: handleContainerPointerMoveEvent, 28 | pointerup: handleContainerPointerUpEvent, 29 | pointercancel: handleContainerPointerUpEvent, 30 | } 31 | 32 | function handleContainerPointerDownEvent(_: PointerEvent) { 33 | state.moveCounter = 0 34 | if (state.pointerCounter === 0) { 35 | // Add to event listener 36 | entriesOf(containerPointerHandlers).forEach(([ev, handler]) => { 37 | document.addEventListener(ev, handler, { passive: true }) 38 | }) 39 | } 40 | state.pointerCounter++ 41 | } 42 | 43 | function handleContainerPointerMoveEvent(_: PointerEvent) { 44 | state.moveCounter++ 45 | } 46 | 47 | function handleContainerPointerUpEvent(event: PointerEvent) { 48 | state.pointerCounter-- 49 | if (state.pointerCounter <= 0) { 50 | state.pointerCounter = 0 51 | // Remove from event listener 52 | entriesOf(containerPointerHandlers).forEach(([ev, handler]) => { 53 | document.removeEventListener(ev, handler) 54 | }) 55 | const threshold = getPointerMoveDetectionThreshold(event.pointerType) 56 | if (state.moveCounter <= threshold) { 57 | // Click container (without mouse move) 58 | if (event.shiftKey && modes.selectionMode.value !== "container") { 59 | return 60 | } 61 | modes.selectionMode.value = "container" 62 | 63 | // click handling 64 | const [clickState, clickEvent, doubleClickEvent] = createClickEvents( 65 | state.clickState, 66 | event, 67 | "view" 68 | ) 69 | state.clickState = clickState 70 | container.value!.dispatchEvent(clickEvent) 71 | if (doubleClickEvent) { 72 | container.value!.dispatchEvent(doubleClickEvent) 73 | } 74 | } 75 | } 76 | } 77 | 78 | function handleContainerClickEvent(event: MouseEvent) { 79 | if (event.isTrusted) return // native event 80 | // When a finger is placed on any object and another object is tapped, 81 | // no click event is fired. Thus, click events are emulated by using 82 | // pointerdown/up. The following is processing for emulated events only. 83 | event.stopPropagation() 84 | emitter.emit("view:click", { event }) 85 | } 86 | 87 | function handleContainerDoubleClickEvent(event: MouseEvent) { 88 | if (event.isTrusted) return // native event 89 | event.stopPropagation() 90 | emitter.emit("view:dblclick", { event }) 91 | } 92 | 93 | function handleContainerContextMenuEvent(event: MouseEvent) { 94 | emitter.emit("view:contextmenu", { event }) 95 | 96 | if (state.pointerCounter > 0) { 97 | // reset pointer down state 98 | state.pointerCounter = 0 99 | // Remove from event listener 100 | entriesOf(containerPointerHandlers).forEach(([ev, handler]) => { 101 | container.value?.removeEventListener(ev, handler) 102 | }) 103 | } 104 | } 105 | 106 | const preventDefault = (e: MouseEvent) => { 107 | e.preventDefault() 108 | } 109 | 110 | onMounted(() => { 111 | const c = container.value 112 | if (!c) return 113 | c.addEventListener("pointerdown", handleContainerPointerDownEvent, { passive: true }) 114 | c.addEventListener("click", handleContainerClickEvent, { passive: false }) 115 | c.addEventListener("dblclick", handleContainerDoubleClickEvent, { passive: false }) 116 | c.addEventListener("contextmenu", handleContainerContextMenuEvent, { passive: false }) 117 | if (isSvgWheelZoomEnabled.value) { 118 | c.addEventListener("wheel", preventDefault, { passive: false }) 119 | } 120 | }) 121 | 122 | onUnmounted(() => { 123 | const c = container.value 124 | if (!c) return 125 | c.removeEventListener("pointerdown", handleContainerPointerDownEvent) 126 | c.removeEventListener("click", handleContainerClickEvent) 127 | c.removeEventListener("dblclick", handleContainerDoubleClickEvent) 128 | c.removeEventListener("contextmenu", handleContainerContextMenuEvent) 129 | if (isSvgWheelZoomEnabled.value) { 130 | c.removeEventListener("wheel", preventDefault) 131 | } 132 | }) 133 | 134 | watch(isSvgWheelZoomEnabled, (enabled, old) => { 135 | const c = container.value 136 | if (!c || enabled === old) return 137 | 138 | if (enabled) { 139 | c.addEventListener("wheel", preventDefault, { passive: false }) 140 | } else { 141 | c.removeEventListener("wheel", preventDefault) 142 | } 143 | }) 144 | } 145 | -------------------------------------------------------------------------------- /src/components/base/VLabelText.vue: -------------------------------------------------------------------------------- 1 | 123 | 124 | 166 | -------------------------------------------------------------------------------- /src/components/background/VBackgroundGrid.vue: -------------------------------------------------------------------------------- 1 | 125 | 126 | 160 | 161 | 166 | -------------------------------------------------------------------------------- /src/composables/mouse/index.ts: -------------------------------------------------------------------------------- 1 | // Module responsible for selection state and mouse/touch operations 2 | 3 | import { inject, InjectionKey, provide, ref, Ref, watch } from "vue" 4 | import { Emitter } from "mitt" 5 | import { nonNull, Reactive, ReadonlyRef } from "@/common/common" 6 | import { Events, Layouts, Rectangle } from "@/common/types" 7 | import { NodeStates } from "@/models/node" 8 | import { EdgeStates } from "@/models/edge" 9 | import { PathStates } from "@/models/path" 10 | import { Configs } from "@/common/configs" 11 | import { InteractionModes } from "./core" 12 | import { makeNodeInteractionHandlers } from "./node" 13 | import { makeEdgeInteractionHandlers } from "./edge" 14 | import { setupContainerInteractionHandlers } from "./container" 15 | import { makePathInteractionHandlers } from "./path" 16 | import { BoxSelectionOption, makeBoxSelectionMethods } from "./boxSelection" 17 | 18 | type NodeEventHandler = (node: string, event: T) => void 19 | type EdgeEventHandler = (edge: string, event: T) => void 20 | type EdgesEventHandler = (edges: string[], event: T) => void 21 | type PathEventHandler = (path: string, event: T) => void 22 | 23 | interface MouseEventHandlers { 24 | selectedNodes: Reactive> 25 | hoveredNodes: Reactive> 26 | selectedEdges: Reactive> 27 | hoveredEdges: Reactive> 28 | selectedPaths: Reactive> 29 | hoveredPaths: Reactive> 30 | 31 | // for Nodes 32 | handleNodePointerDownEvent: NodeEventHandler 33 | handleNodePointerOverEvent: NodeEventHandler 34 | handleNodePointerOutEvent: NodeEventHandler 35 | handleNodeClickEvent: NodeEventHandler 36 | handleNodeDoubleClickEvent: NodeEventHandler 37 | handleNodeContextMenu: NodeEventHandler 38 | 39 | // for Edges 40 | handleEdgePointerDownEvent: EdgeEventHandler 41 | handleEdgePointerOverEvent: EdgeEventHandler 42 | handleEdgePointerOutEvent: EdgeEventHandler 43 | handleEdgeClickEvent: EdgeEventHandler 44 | handleEdgeDoubleClickEvent: EdgeEventHandler 45 | handleEdgeContextMenu: EdgeEventHandler 46 | handleEdgesPointerDownEvent: EdgesEventHandler 47 | handleEdgesPointerOverEvent: EdgesEventHandler 48 | handleEdgesPointerOutEvent: EdgesEventHandler 49 | handleEdgesClickEvent: EdgesEventHandler 50 | handleEdgesDoubleClickEvent: EdgesEventHandler 51 | handleEdgesContextMenu: EdgesEventHandler 52 | 53 | // for Paths 54 | handlePathPointerDownEvent: PathEventHandler 55 | handlePathPointerOverEvent: PathEventHandler 56 | handlePathPointerOutEvent: PathEventHandler 57 | handlePathClickEvent: PathEventHandler 58 | handlePathDoubleClickEvent: PathEventHandler 59 | handlePathContextMenu: PathEventHandler 60 | 61 | // for Box Selection 62 | isBoxSelectionMode: Ref 63 | selectionBox: Ref 64 | startBoxSelection: (options?: Partial) => void 65 | stopBoxSelection: () => void 66 | } 67 | const mouseEventHandlersKey = Symbol("mouseEventHandlers") as InjectionKey 68 | 69 | export function provideMouseOperation( 70 | container: Ref, 71 | layouts: Readonly, 72 | zoomLevel: ReadonlyRef, 73 | nodeStates: NodeStates, 74 | edgeStates: EdgeStates, 75 | pathStates: PathStates, 76 | selectedNodes: Reactive>, 77 | selectedEdges: Reactive>, 78 | selectedPaths: Reactive>, 79 | hoveredNodes: Reactive>, 80 | hoveredEdges: Reactive>, 81 | hoveredPaths: Reactive>, 82 | isInCompatibilityModeForPath: Ref, 83 | isSvgWheelZoomEnabled: Ref, 84 | configs: Configs, 85 | emitter: Emitter 86 | ): MouseEventHandlers { 87 | const modes: InteractionModes = { 88 | selectionMode: ref("container"), 89 | viewMode: ref("default"), 90 | } 91 | 92 | if (selectedNodes.size > 0) { 93 | modes.selectionMode.value = "node" 94 | } else if (selectedEdges.size > 0) { 95 | modes.selectionMode.value = "edge" 96 | } else if (selectedPaths.size > 0) { 97 | modes.selectionMode.value = "path" 98 | } 99 | 100 | watch(modes.viewMode, mode => { 101 | emitter.emit("view:mode", mode) 102 | }) 103 | 104 | setupContainerInteractionHandlers(container, modes, isSvgWheelZoomEnabled, emitter) 105 | 106 | const provides = { 107 | selectedNodes, 108 | hoveredNodes, 109 | selectedEdges, 110 | hoveredEdges, 111 | selectedPaths, 112 | hoveredPaths, 113 | ...makeNodeInteractionHandlers( 114 | nodeStates, 115 | layouts, 116 | modes, 117 | hoveredNodes, 118 | selectedNodes, 119 | zoomLevel, 120 | emitter 121 | ), 122 | ...makeEdgeInteractionHandlers(edgeStates, modes, hoveredEdges, selectedEdges, emitter), 123 | ...makePathInteractionHandlers( 124 | pathStates, 125 | modes, 126 | hoveredPaths, 127 | selectedPaths, 128 | isInCompatibilityModeForPath, 129 | emitter 130 | ), 131 | ...makeBoxSelectionMethods( 132 | container, 133 | modes, 134 | layouts, 135 | nodeStates, 136 | selectedNodes, 137 | configs 138 | ), 139 | } 140 | provide(mouseEventHandlersKey, provides) 141 | return provides 142 | } 143 | 144 | export function useMouseOperation(): MouseEventHandlers { 145 | return nonNull(inject(mouseEventHandlersKey), "mouseEventHandlers") 146 | } 147 | -------------------------------------------------------------------------------- /src/composables/objectState.ts: -------------------------------------------------------------------------------- 1 | // Management states of objects 2 | 3 | import { computed, ComputedRef, reactive, Ref, unref, UnwrapRef, watch } from "vue" 4 | import { Reactive } from "@/common/common" 5 | import { Config, ObjectConfigs, ZOrderConfig } from "@/common/configs" 6 | 7 | type Objects = Record 8 | 9 | interface ObjectStateDatumBase { 10 | id: string 11 | selected: boolean 12 | hovered: boolean 13 | selectable: ComputedRef 14 | zIndex: ComputedRef 15 | } 16 | type ObjectState = UnwrapRef 17 | 18 | type PartiallyPartial = Pick & Partial> 19 | type NewStateDatum = PartiallyPartial 20 | 21 | export function useObjectState< 22 | T, 23 | S extends ObjectStateDatumBase, 24 | E extends { id: string; zIndex: number } = ObjectState 25 | >( 26 | objects: Ref>, 27 | config: ObjectConfigs, 28 | selected: Reactive>, 29 | hovered: Reactive>, 30 | createState: (obj: Ref>, id: string, state: NewStateDatum) => void, 31 | terminateState?: (id: string, state: ObjectState) => void, 32 | entriesForZOrder?: () => E[] 33 | ): { 34 | states: Record> 35 | zOrderedList: ComputedRef 36 | } { 37 | // Object states 38 | const states: Record> = reactive({}) 39 | 40 | // Handle object added/removed 41 | watch( 42 | () => new Set(Object.keys(objects.value)), 43 | (idSet, prev) => { 44 | if (!prev) prev = new Set([]) 45 | for (const id of idSet) { 46 | if (prev.has(id)) continue 47 | // object added 48 | createNewState(objects, states, id, false, config, createState) 49 | // adding to layouts is done by layout handler 50 | } 51 | 52 | for (const id of prev) { 53 | if (idSet.has(id)) continue 54 | // object removed 55 | selected.delete(id) 56 | hovered.delete(id) 57 | terminateState?.(id, states[id] as ObjectState) 58 | delete states[id] 59 | } 60 | }, 61 | { immediate: true } 62 | ) 63 | 64 | // Object selection 65 | // - update `{obj}.selected` flag 66 | watch( 67 | () => [...selected], 68 | (objects, prev) => { 69 | const append = prev ? objects.filter(n => !prev.includes(n)) : objects 70 | const removed = prev ? prev.filter(n => !objects.includes(n)) : [] 71 | append.forEach(id => { 72 | const state = states[id] 73 | if (state && !state.selected) state.selected = true 74 | }) 75 | removed.forEach(id => { 76 | const state = states[id] 77 | if (state && state.selected) state.selected = false 78 | }) 79 | }, 80 | { immediate: true } // for specified from the beginning 81 | ) 82 | 83 | // - update `node.hovered` flag 84 | watch( 85 | () => [...hovered], 86 | (nodes, prev) => { 87 | const append = nodes.filter(n => !prev.includes(n)) 88 | const removed = prev.filter(n => !nodes.includes(n)) 89 | append.forEach(id => { 90 | const state = states[id] 91 | if (state && !state.hovered) state.hovered = true 92 | }) 93 | removed.forEach(id => { 94 | const state = states[id] 95 | if (state && state.hovered) state.hovered = false 96 | }) 97 | } 98 | ) 99 | 100 | // z-order 101 | // z-index applied Object List 102 | const zOrderedList = computed(() => { 103 | const list: E[] = entriesForZOrder ? entriesForZOrder() : (Object.values(states) as E[]) 104 | if (config.zOrder.enabled) { 105 | return makeZOrderedList(list, config.zOrder, hovered, selected) 106 | } else { 107 | return list 108 | } 109 | }) 110 | 111 | return { states, zOrderedList } 112 | } 113 | 114 | function createNewState( 115 | objects: Ref>, 116 | states: Record>, 117 | id: string, 118 | selected: boolean, 119 | config: ObjectConfigs, 120 | createState: (obj: Ref>, id: string, state: NewStateDatum) => void 121 | ) { 122 | const stateObject = >{ 123 | id, 124 | selected, 125 | hovered: false, 126 | selectable: computed(() => { 127 | if (!objects.value[id]) return unref(stateObject.selectable) // Return the previous value 128 | return Config.value(config.selectable, objects.value[id]) 129 | }), 130 | zIndex: computed(() => { 131 | if (!objects.value[id]) return unref(stateObject.zIndex) // Return the previous value 132 | return Config.value(config.zOrder.zIndex, objects.value[id]) 133 | }), 134 | } 135 | states[id] = stateObject as ObjectState 136 | createState(objects, id, states[id] as NewStateDatum /* get reactive object */) 137 | } 138 | 139 | function makeZOrderedList( 140 | states: S[], 141 | zOrder: ZOrderConfig, 142 | hovered: Reactive>, 143 | selected: Reactive> 144 | ) { 145 | if (zOrder.bringToFrontOnHover && zOrder.bringToFrontOnSelected) { 146 | return states.sort((a, b) => { 147 | const hover1 = hovered.has(a.id) 148 | const hover2 = hovered.has(b.id) 149 | if (hover1 != hover2) { 150 | return hover1 ? 1 : -1 151 | } 152 | const selected1 = selected.has(a.id) 153 | const selected2 = selected.has(b.id) 154 | if (selected1 != selected2) { 155 | return selected1 ? 1 : -1 156 | } 157 | return a.zIndex - b.zIndex 158 | }) 159 | } else if (zOrder.bringToFrontOnHover) { 160 | return states.sort((a, b) => { 161 | const hover1 = hovered.has(a.id) 162 | const hover2 = hovered.has(b.id) 163 | if (hover1 != hover2) { 164 | return hover1 ? 1 : -1 165 | } 166 | return a.zIndex - b.zIndex 167 | }) 168 | } else if (zOrder.bringToFrontOnSelected) { 169 | return states.sort((a, b) => { 170 | const selected1 = selected.has(a.id) 171 | const selected2 = selected.has(b.id) 172 | if (selected1 != selected2) { 173 | return selected1 ? 1 : -1 174 | } 175 | return a.zIndex - b.zIndex 176 | }) 177 | } else { 178 | return states.sort((a, b) => { 179 | return a.zIndex - b.zIndex 180 | }) 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/common/types.ts: -------------------------------------------------------------------------------- 1 | import { RecursivePartial } from "./common" 2 | 3 | /* ------------------------------------------ * 4 | * Core types 5 | * ------------------------------------------ */ 6 | 7 | export interface Position { 8 | x: number 9 | y: number 10 | } 11 | 12 | export interface LinePosition { 13 | p1: Position 14 | p2: Position 15 | } 16 | 17 | export interface Size { 18 | width: number 19 | height: number 20 | } 21 | 22 | export interface Rectangle { 23 | pos: Position 24 | size: Size 25 | } 26 | 27 | /** An object with a field named id */ 28 | export interface IdentifiedObject { 29 | id: string 30 | } 31 | 32 | export interface Box { 33 | top: number 34 | bottom: number 35 | left: number 36 | right: number 37 | } 38 | 39 | /* ------------------------------------------ * 40 | * Network graph elements 41 | * ------------------------------------------ */ 42 | 43 | export interface Node { 44 | name?: string 45 | // any properties 46 | [x: string]: any 47 | } 48 | 49 | export type Nodes = Record 50 | export type NodeWithId = Node & IdentifiedObject 51 | 52 | export interface Edge { 53 | source: string 54 | target: string 55 | // any properties 56 | [x: string]: any 57 | } 58 | 59 | export type Edges = Record 60 | export type EdgeWithId = Edge & IdentifiedObject 61 | 62 | export type LayerName = "edges" | "edge-labels" | "focusring" | "nodes" | "node-labels" | "paths" 63 | 64 | export type LayerPosition = LayerName | "base" | "grid" | "background" | "root" 65 | 66 | export type Layers = Record 67 | 68 | export const LayerPositions: readonly LayerPosition[] = [ 69 | "paths", 70 | "node-labels", 71 | "nodes", 72 | "focusring", 73 | "edge-labels", 74 | "edges", 75 | "base", 76 | "grid", 77 | "background", 78 | "root", 79 | ] 80 | 81 | /* ------------------------------------------ * 82 | * View 83 | * ------------------------------------------ */ 84 | 85 | export type ViewMode = "default" | "node" | "edge" | "path" | "box-selection" 86 | 87 | /* ------------------------------------------ * 88 | * Layouts 89 | * ------------------------------------------ */ 90 | 91 | export interface FixablePosition extends Position { 92 | fixed?: boolean 93 | } 94 | 95 | export type NodePositions = Record 96 | 97 | export interface Layouts { 98 | nodes: NodePositions 99 | } 100 | /** for User Specified */ 101 | export type UserLayouts = RecursivePartial 102 | 103 | /* ------------------------------------------ * 104 | * Edge labels 105 | * ------------------------------------------ */ 106 | 107 | export interface EdgePosition { 108 | source: Position 109 | target: Position 110 | } 111 | 112 | export interface EdgeLabelArea { 113 | source: { 114 | above: Position 115 | below: Position 116 | } 117 | target: { 118 | above: Position 119 | below: Position 120 | } 121 | } 122 | 123 | /* ------------------------------------------ * 124 | * Paths 125 | * ------------------------------------------ */ 126 | 127 | export interface Path { 128 | id?: string 129 | edges: string[] 130 | // any properties 131 | [x: string]: any 132 | } 133 | 134 | export type Paths = Record 135 | 136 | // When specified in a list, the ID is not needed for a while to 137 | // keep compatibility. 138 | // TODO: After a while, remove `| Path[]`. 139 | export type InputPaths = Record | Path[] 140 | 141 | // line: point | curve: [control-point, control-point, target-point] | "arc" | move to next point: null 142 | export type PositionOrCurve = Position | Position[] | string | null 143 | 144 | /* ------------------------------------------ * 145 | * Events 146 | * ------------------------------------------ */ 147 | 148 | export type ViewEvent = { event: T } 149 | export type NodeEvent = { node: string; event: T } 150 | export type EdgeEvent = 151 | | { edge: string; edges: string[]; summarized: false; event: T } 152 | | { edge?: undefined; edges: string[]; summarized: true; event: T } 153 | export type PathEvent = { path: string; event: T } 154 | 155 | // For compatibility with previous versions 156 | export type NodePointerEvent = NodeEvent 157 | export type EdgePointerEvent = EdgeEvent 158 | 159 | export type Events = { 160 | "view:load": undefined 161 | "view:unload": undefined 162 | "view:mode": ViewMode 163 | "view:zoom": number 164 | "view:pan": { x: number; y: number } 165 | "view:fit": undefined 166 | "view:resize": { x: number; y: number; width: number; height: number } 167 | "view:click": ViewEvent 168 | "view:dblclick": ViewEvent 169 | "view:contextmenu": ViewEvent 170 | "node:click": NodeEvent 171 | "node:dblclick": NodeEvent 172 | "node:pointerover": NodeEvent 173 | "node:pointerout": NodeEvent 174 | "node:pointerup": NodeEvent 175 | "node:pointerdown": NodeEvent 176 | "node:contextmenu": NodeEvent 177 | "node:dragstart": { [name: string]: Position } 178 | "node:pointermove": { [name: string]: Position } 179 | "node:dragend": { [name: string]: Position } 180 | "node:select": string[] 181 | "edge:pointerup": EdgeEvent 182 | "edge:pointerdown": EdgeEvent 183 | "edge:click": EdgeEvent 184 | "edge:dblclick": EdgeEvent 185 | "edge:pointerover": EdgeEvent 186 | "edge:pointerout": EdgeEvent 187 | "edge:contextmenu": EdgeEvent 188 | "edge:select": string[] 189 | "path:select": string[] 190 | "path:pointerup": PathEvent 191 | "path:pointerdown": PathEvent 192 | "path:click": PathEvent 193 | "path:dblclick": PathEvent 194 | "path:pointerover": PathEvent 195 | "path:pointerout": PathEvent 196 | "path:contextmenu": PathEvent 197 | } 198 | 199 | export type EventHandlers = { 200 | "*"?: (type: T, event: Events[T]) => void 201 | } & { 202 | [K in keyof Events]?: (event: Events[K]) => void 203 | } 204 | 205 | export type OnClickHandler = (param: NodeEvent) => void 206 | export type OnDragHandler = (param: { [name: string]: Position }) => void 207 | 208 | /* ------------------------------------------ * 209 | * SVG area 210 | * ------------------------------------------ */ 211 | 212 | export interface Point { 213 | x: number 214 | y: number 215 | } 216 | 217 | export interface ViewBox { 218 | x: number 219 | y: number 220 | width: number 221 | height: number 222 | } 223 | 224 | export interface Sizes { 225 | width: number 226 | height: number 227 | viewBox: ViewBox 228 | } 229 | -------------------------------------------------------------------------------- /tests/modules/vector/methods.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest" 2 | import * as Methods from "../../../src/modules/vector2d" 3 | import { Point2D } from "../../../src/modules/vector2d/core" 4 | 5 | function testArgumentAndResult(v: Point2D, target: Point2D, result1: Point2D, result2: Point2D) { 6 | it("should not overwrite arguments", () => { 7 | expect(result1).to.not.equals(v) 8 | expect(result2).to.not.equals(v) 9 | }) 10 | 11 | it("should write to target", () => { 12 | expect(result2).to.equal(target) 13 | }) 14 | } 15 | 16 | function testArgumentsAndResult( 17 | v1: Point2D, 18 | v2: Point2D, 19 | target: Point2D, 20 | result1: Point2D, 21 | result2: Point2D 22 | ) { 23 | it("should not overwrite arguments", () => { 24 | expect(result1).to.not.equals(v1) 25 | expect(result1).to.not.equals(v2) 26 | expect(result2).to.not.equals(v1) 27 | expect(result2).to.not.equals(v2) 28 | }) 29 | 30 | it("should write to target", () => { 31 | expect(result2).to.equal(target) 32 | }) 33 | } 34 | 35 | describe("methods", () => { 36 | describe("add", () => { 37 | const v1 = { x: 10, y: 20 } 38 | const v2 = { x: 30, y: 40 } 39 | const target = { x: 0, y: 0 } 40 | 41 | const result1 = Methods.add(v1, v2) 42 | const result2 = Methods.add(v1, v2, target) 43 | 44 | it("should add vectors", () => { 45 | expect(result1).to.eql({ x: 40, y: 60 }) 46 | expect(result2).to.eql({ x: 40, y: 60 }) 47 | }) 48 | 49 | testArgumentsAndResult(v1, v2, target, result1, result2) 50 | }) 51 | 52 | describe("subtract", () => { 53 | const v1 = { x: 10, y: 20 } 54 | const v2 = { x: 30, y: 10 } 55 | const target = { x: 0, y: 0 } 56 | 57 | const result1 = Methods.subtract(v1, v2) 58 | const result2 = Methods.subtract(v1, v2, target) 59 | 60 | it("should subtract vectors", () => { 61 | expect(result1).to.eql({ x: -20, y: 10 }) 62 | expect(result2).to.eql({ x: -20, y: 10 }) 63 | }) 64 | 65 | testArgumentsAndResult(v1, v2, target, result1, result2) 66 | }) 67 | 68 | describe("multiply", () => { 69 | const v1 = { x: 10, y: 20 } 70 | const v2 = { x: 2, y: 2 } 71 | const target = { x: 0, y: 0 } 72 | 73 | const result1 = Methods.multiply(v1, v2) 74 | const result2 = Methods.multiply(v1, v2, target) 75 | 76 | it("should multiply vectors", () => { 77 | expect(result1).to.eql({ x: 20, y: 40 }) 78 | expect(result2).to.eql({ x: 20, y: 40 }) 79 | }) 80 | 81 | testArgumentsAndResult(v1, v2, target, result1, result2) 82 | }) 83 | 84 | describe("multiplyScalar", () => { 85 | const v = { x: 10, y: 20 } 86 | const target = { x: 0, y: 0 } 87 | 88 | const result1 = Methods.multiplyScalar(v, 2) 89 | const result2 = Methods.multiplyScalar(v, 2, target) 90 | 91 | it("should multiply both axis", () => { 92 | expect(result1).to.eql({ x: 20, y: 40 }) 93 | expect(result2).to.eql({ x: 20, y: 40 }) 94 | }) 95 | 96 | testArgumentAndResult(v, target, result1, result2) 97 | }) 98 | 99 | describe("divide", () => { 100 | const v1 = { x: 10, y: 20 } 101 | const v2 = { x: 2, y: 2 } 102 | const target = { x: 0, y: 0 } 103 | 104 | const result1 = Methods.divide(v1, v2) 105 | const result2 = Methods.divide(v1, v2, target) 106 | 107 | it("should divide vectors", () => { 108 | expect(result1).to.eql({ x: 5, y: 10 }) 109 | expect(result2).to.eql({ x: 5, y: 10 }) 110 | }) 111 | 112 | testArgumentsAndResult(v1, v2, target, result1, result2) 113 | }) 114 | 115 | describe("dot", () => { 116 | const v1 = { x: 100, y: 100 } 117 | const v2 = { x: 200, y: 200 } 118 | const result = Methods.dot(v1, v2) 119 | 120 | it("should calculate dot product", () => { 121 | expect(result).to.be.equal(40000) 122 | }) 123 | }) 124 | 125 | describe("cross", () => { 126 | const v1 = { x: 100, y: 100 } 127 | const v2 = { x: 400, y: 200 } 128 | const result = Methods.cross(v1, v2) 129 | 130 | it("should calculate cross product", () => { 131 | expect(result).to.be.equal(-20000) 132 | }) 133 | }) 134 | 135 | describe("length", () => { 136 | const v = { x: 3, y: 4 } 137 | const result1 = Methods.length(v) 138 | const result2 = Methods.lengthSquared(v) 139 | 140 | it("should calculate length", () => { 141 | expect(result1).to.be.equal(5) 142 | expect(result2).to.be.equal(25) 143 | }) 144 | }) 145 | 146 | describe("distance", () => { 147 | const v1 = { x: 1, y: 10 } 148 | const v2 = { x: 4, y: 6 } 149 | const result1 = Methods.distance(v1, v2) 150 | const result2 = Methods.distanceSquared(v1, v2) 151 | 152 | it("should calculate length", () => { 153 | expect(result1).to.be.equal(5) 154 | expect(result2).to.be.equal(25) 155 | }) 156 | }) 157 | 158 | describe("normalize", () => { 159 | const v = { x: 3, y: 4 } 160 | const zero = { x: 0, y: 0 } 161 | const result1 = Methods.normalize(v) 162 | const result2 = Methods.normalize(zero) 163 | 164 | it("should normalize a vector", () => { 165 | expect(result1).to.be.eql({ x: 3 / 5, y: 4 / 5 }) 166 | expect(result2).to.be.eql({ x: 1, y: 0 }) 167 | }) 168 | }) 169 | 170 | describe("angle", () => { 171 | const angleX = Methods.angle({ x: 100, y: 0 }) 172 | const angleY = Methods.angle({ x: 0, y: 100 }) 173 | const anglePi = Methods.angle({ x: -100, y: 0 }) 174 | 175 | it("should x directed vector to 0 degree", () => { 176 | expect(angleX).to.be.equal(0) 177 | }) 178 | it("should x directed vector to 90 degree", () => { 179 | expect(angleY).to.be.equal(Math.PI / 2) 180 | }) 181 | it("should x directed vector to 180 degree", () => { 182 | expect(anglePi).to.be.equal(Math.PI) 183 | }) 184 | }) 185 | 186 | describe("angleDegree", () => { 187 | const angleX = Methods.angleDegree({ x: 100, y: 0 }) 188 | const angleY = Methods.angleDegree({ x: 0, y: 100 }) 189 | const anglePi = Methods.angleDegree({ x: -100, y: 0 }) 190 | 191 | it("should x directed vector to 0 degree", () => { 192 | expect(angleX).to.be.equal(0) 193 | }) 194 | it("should x directed vector to 90 degree", () => { 195 | expect(angleY).to.be.equal(90) 196 | }) 197 | it("should x directed vector to 180 degree", () => { 198 | expect(anglePi).to.be.equal(180) 199 | }) 200 | }) 201 | 202 | describe("rotate", () => { 203 | const v = { x: 10, y: 10 } 204 | const target = { x: 0, y: 0 } 205 | const angle = (90 * Math.PI) / 180 206 | const result1 = Methods.rotate(v, angle) 207 | const result2 = Methods.rotate(v, angle, target) 208 | 209 | it("should rotate a vector", () => { 210 | expect(result1).to.be.eql({ x: -10, y: 10 }) 211 | expect(result2).to.be.eql({ x: -10, y: 10 }) 212 | }) 213 | 214 | testArgumentAndResult(v, target, result1, result2) 215 | }) 216 | }) 217 | -------------------------------------------------------------------------------- /src/modules/svg-pan-zoom-ex.ts: -------------------------------------------------------------------------------- 1 | import svgPanZoom, * as SvgPanZoom from "@dash14/svg-pan-zoom" 2 | 3 | export interface Box { 4 | top: number 5 | bottom: number 6 | left: number 7 | right: number 8 | } 9 | 10 | interface ViewArea { 11 | box: Box 12 | center: SvgPanZoom.Point 13 | } 14 | 15 | export interface SvgPanZoomInstance extends SvgPanZoom.Instance { 16 | getViewArea(): ViewArea 17 | getViewBox(): Box 18 | setViewBox(box: Box): void 19 | getRealZoom(): number 20 | applyAbsoluteZoomLevel(zoomLevel: number, minZoomLevel: number, maxZoomLevel: number): void 21 | setPanEnabled(enabled: boolean): SvgPanZoomInstance 22 | setZoomEnabled(enabled: boolean): SvgPanZoomInstance 23 | } 24 | 25 | export interface CustomEventOptions { 26 | svgElement: SVGSVGElement; 27 | instance: SvgPanZoomInstance; 28 | } 29 | 30 | export interface CustomEventHandler { 31 | init: (options: CustomEventOptions) => void; 32 | haltEventListeners: string[]; 33 | destroy: (options: CustomEventOptions) => void; 34 | } 35 | 36 | export interface SvgPanZoomOptions extends Omit { 37 | customEventsHandler?: CustomEventHandler; // (default null) 38 | } 39 | 40 | export interface SvgPanZoomInternal extends SvgPanZoomInstance { 41 | _isPanEnabled: boolean 42 | _isZoomEnabled: boolean 43 | _internalIsPanEnabled(): boolean 44 | _internalEnablePan(): void 45 | _internalDisablePan(): void 46 | _internalIsZoomEnabled(): boolean 47 | _internalEnableZoom(): void 48 | _internalDisableZoom(): void 49 | } 50 | 51 | const methods: Partial = { 52 | getViewArea(this: SvgPanZoomInternal) { 53 | const sizes = this.getSizes() 54 | const pan = this.getPan() 55 | const scale = sizes.realZoom 56 | pan.x /= scale 57 | pan.y /= scale 58 | const viewport = { 59 | width: sizes.width / scale, 60 | height: sizes.height / scale, 61 | } 62 | return { 63 | box: { 64 | top: -pan.y, 65 | bottom: viewport.height - pan.y, 66 | left: -pan.x, 67 | right: viewport.width - pan.x, 68 | }, 69 | center: { 70 | x: viewport.width / 2 - pan.x, 71 | y: viewport.height / 2 - pan.y, 72 | }, 73 | } 74 | }, 75 | getViewBox(this: SvgPanZoomInternal): Box { 76 | return this.getViewArea().box 77 | }, 78 | setViewBox(this: SvgPanZoomInternal, box: Box) { 79 | // Adjust zoom and pan to include the box. 80 | // If the aspect ratio is different from the box, pan to 81 | // include the box with keeping the center. 82 | const width = box.right - box.left 83 | const height = box.bottom - box.top 84 | const { width: sizeWidth, height: sizeHeight } = this.getSizes() 85 | const ratio = width / height 86 | const currentRatio = sizeWidth / sizeHeight 87 | const newWidth = ratio < currentRatio ? height * currentRatio : width 88 | const newHeight = ratio > currentRatio ? width / currentRatio : height 89 | const absoluteZoom = Math.min( 90 | sizeWidth / newWidth, 91 | sizeHeight / newHeight 92 | ) 93 | const realZoom = this.getRealZoom() 94 | const relativeZoom = this.getZoom() 95 | const originalZoom = realZoom / relativeZoom 96 | this.zoom(absoluteZoom / originalZoom) 97 | 98 | const center = { 99 | x: (box.left + width / 2) * absoluteZoom, 100 | y: (box.top + height / 2) * absoluteZoom 101 | } 102 | this.pan({ 103 | x: -(center.x) + newWidth / 2 * absoluteZoom, 104 | y: -(center.y) + newHeight / 2 * absoluteZoom 105 | }) 106 | }, 107 | getRealZoom(this: SvgPanZoomInternal) { 108 | return this.getSizes().realZoom 109 | }, 110 | applyAbsoluteZoomLevel(this: SvgPanZoomInternal, zoomLevel: number, minZoomLevel: number, maxZoomLevel: number) { 111 | // normalize 112 | const min = Math.max(0.0001, minZoomLevel) 113 | const max = Math.max(min, maxZoomLevel) 114 | const zoom = Math.max(Math.min(max, zoomLevel), min) 115 | 116 | const realZoom = this.getRealZoom() 117 | const relativeZoom = this.getZoom() 118 | const originalZoom = realZoom / relativeZoom 119 | 120 | this.setMinZoom(min / originalZoom) 121 | .setMaxZoom(max / originalZoom) 122 | .zoom(zoom / originalZoom) 123 | }, 124 | isPanEnabled(this: SvgPanZoomInternal) { 125 | return this._isPanEnabled 126 | }, 127 | enablePan(this: SvgPanZoomInternal) { 128 | this._isPanEnabled = true 129 | this._internalEnablePan() 130 | return this 131 | }, 132 | disablePan(this: SvgPanZoomInternal) { 133 | this._isPanEnabled = false 134 | this._internalDisablePan() 135 | return this 136 | }, 137 | isZoomEnabled(this: SvgPanZoomInternal) { 138 | return this._isZoomEnabled 139 | }, 140 | enableZoom(this: SvgPanZoomInternal) { 141 | this._isZoomEnabled = true 142 | this._internalEnableZoom() 143 | return this 144 | }, 145 | disableZoom(this: SvgPanZoomInternal) { 146 | this._isZoomEnabled = false 147 | this._internalDisableZoom() 148 | return this 149 | }, 150 | setPanEnabled(this: SvgPanZoomInternal, enabled: boolean) { 151 | if (enabled) { 152 | this.enablePan() 153 | } else { 154 | this.disablePan() 155 | } 156 | return this 157 | }, 158 | setZoomEnabled(this: SvgPanZoomInternal, enabled: boolean) { 159 | if (enabled) { 160 | this.enableZoom() 161 | this.enableDblClickZoom() 162 | } else { 163 | this.disableZoom() 164 | this.disableDblClickZoom() 165 | } 166 | return this 167 | }, 168 | } 169 | 170 | function constructor( 171 | svgPanZoom: SvgPanZoom.Instance, 172 | options: SvgPanZoomOptions, 173 | ): SvgPanZoomInternal { 174 | const instance = svgPanZoom as SvgPanZoomInternal 175 | instance._isPanEnabled = options.panEnabled ?? true 176 | instance._isZoomEnabled = options?.zoomEnabled ?? true 177 | instance._internalIsPanEnabled = instance.isPanEnabled 178 | instance._internalEnablePan = instance.enablePan 179 | instance._internalDisablePan = instance.disablePan 180 | instance._internalIsZoomEnabled = instance.isZoomEnabled 181 | instance._internalEnableZoom = instance.enableZoom 182 | instance._internalDisableZoom = instance.disableZoom 183 | Object.assign(svgPanZoom, methods) 184 | return instance 185 | } 186 | 187 | export function createSvgPanZoomEx( 188 | svg: SVGElement, 189 | options: SvgPanZoomOptions 190 | ): SvgPanZoomInstance { 191 | 192 | const userInit = options.customEventsHandler?.init ?? ((_: any) => {}) 193 | const userDestroy = options.customEventsHandler?.destroy ?? ((_: any) => {}) 194 | const haltEventListeners = options.customEventsHandler?.haltEventListeners ?? [] 195 | 196 | if (options.mouseWheelZoomEnabled === undefined) { 197 | options.mouseWheelZoomEnabled = options.zoomEnabled 198 | } 199 | 200 | options.customEventsHandler = { 201 | init: o => { 202 | constructor(o.instance, options) 203 | userInit(o) 204 | }, 205 | destroy: o => userDestroy(o), 206 | haltEventListeners 207 | } 208 | 209 | return svgPanZoom(svg, options as SvgPanZoom.Options) as SvgPanZoomInternal 210 | } 211 | --------------------------------------------------------------------------------