├── 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 |
2 |
3 |
4 |
5 |
6 |
7 |
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 |
5 |
6 |
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 |
15 |
22 |
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 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/src/components/edge/VEdgeBackgrounds.vue:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
10 |
11 |
12 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/components/svg/VSvgCircle.vue:
--------------------------------------------------------------------------------
1 |
17 |
18 |
19 |
29 |
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 |
13 |
14 |
15 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/src/components/base/VSelectionBox.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
23 |
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 |
20 |
33 |
34 |
--------------------------------------------------------------------------------
/src/components/layers/VEdgesLayer.vue:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
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 |
31 |
39 |
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 |
17 |
28 |
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 |
40 |
41 |
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 |
24 |
25 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
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 |
33 |
42 |
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 |
21 |
27 |
28 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/src/components/background/VBackgroundViewport.vue:
--------------------------------------------------------------------------------
1 |
38 |
39 |
40 |
41 |
42 |
43 |
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 | [](https://github.com/sponsors/dash14)
11 |
12 | * [Buy Me A Coffee](https://www.buymeacoffee.com/dash14.ack)
13 |
14 | [](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 |
49 |
59 |
60 |
--------------------------------------------------------------------------------
/src/components/edge/VEdgeLabelsPlace.vue:
--------------------------------------------------------------------------------
1 |
8 |
46 |
47 |
56 |
57 |
--------------------------------------------------------------------------------
/src/components/base/VArc.vue:
--------------------------------------------------------------------------------
1 |
42 |
43 |
44 |
54 |
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 |
43 |
54 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/src/components/layers/VNodesLayer.vue:
--------------------------------------------------------------------------------
1 |
17 |
18 |
19 |
20 |
26 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
46 |
52 |
59 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/src/components/base/VShape.vue:
--------------------------------------------------------------------------------
1 |
49 |
50 |
51 |
62 |
76 |
77 |
--------------------------------------------------------------------------------
/src/components/layers/VNodeLabelsLayer.vue:
--------------------------------------------------------------------------------
1 |
26 |
27 |
28 |
29 |
35 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
56 |
63 |
64 |
65 |
66 |
--------------------------------------------------------------------------------
/src/components/edge/VEdge.vue:
--------------------------------------------------------------------------------
1 |
23 |
24 |
25 |
37 |
47 |
57 |
58 |
59 |
69 |
--------------------------------------------------------------------------------
/src/components/edge/VEdgeCurved.vue:
--------------------------------------------------------------------------------
1 |
47 |
48 |
49 |
64 |
76 |
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 |
57 |
70 |
71 |
72 |
79 |
--------------------------------------------------------------------------------
/src/components/edge/VEdgeGroups.vue:
--------------------------------------------------------------------------------
1 |
39 |
40 |
41 |
42 |
43 |
48 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
68 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
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 |
46 |
47 |
48 |
49 |
50 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
86 |
--------------------------------------------------------------------------------
/src/components/node/VNodeFocusRing.vue:
--------------------------------------------------------------------------------
1 |
65 |
66 |
67 |
73 |
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, "