@linklabjs/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +411 -0
- package/package.json +48 -0
- package/src/api/DomainNode.ts +1433 -0
- package/src/api/Graph.ts +271 -0
- package/src/api/PathBuilder.ts +247 -0
- package/src/api/index.ts +15 -0
- package/src/api/loadGraph.ts +207 -0
- package/src/api/test-api.ts +153 -0
- package/src/api/test-domain.ts +119 -0
- package/src/api/types.ts +88 -0
- package/src/config/synonyms.json +28 -0
- package/src/core/EventBus.ts +187 -0
- package/src/core/GraphEvents.ts +153 -0
- package/src/core/PathFinder.ts +283 -0
- package/src/formatters/BaseFormatter.ts +17 -0
- package/src/graph/GraphAssembler.ts +50 -0
- package/src/graph/GraphCompiler.ts +412 -0
- package/src/graph/GraphExtractor.ts +191 -0
- package/src/graph/GraphOptimizer.ts +404 -0
- package/src/graph/GraphTrainer.ts +247 -0
- package/src/http/LinkBuilder.ts +244 -0
- package/src/http/TrailRequest.ts +48 -0
- package/src/http/example-netflix.ts +59 -0
- package/src/http/hateoas/README.md +87 -0
- package/src/http/index.ts +33 -0
- package/src/http/plugin.ts +360 -0
- package/src/index.ts +121 -0
- package/src/instrumentation/TelemetryShim.ts +172 -0
- package/src/navigation/NavigationEngine.ts +441 -0
- package/src/navigation/Resolver.ts +134 -0
- package/src/navigation/Scheduler.ts +136 -0
- package/src/navigation/Trail.ts +252 -0
- package/src/navigation/TrailParser.ts +207 -0
- package/src/navigation/index.ts +11 -0
- package/src/providers/MockProvider.ts +68 -0
- package/src/providers/PostgresProvider.ts +187 -0
- package/src/runtime/CompiledGraphEngine.ts +274 -0
- package/src/runtime/DataLoader.ts +236 -0
- package/src/runtime/Engine.ts +163 -0
- package/src/runtime/QueryEngine.ts +222 -0
- package/src/scenarios/test-metro-paris/config.json +6 -0
- package/src/scenarios/test-metro-paris/graph.json +16325 -0
- package/src/scenarios/test-metro-paris/queries.ts +152 -0
- package/src/scenarios/test-metro-paris/stack.json +1 -0
- package/src/scenarios/test-musicians/config.json +10 -0
- package/src/scenarios/test-musicians/graph.json +20 -0
- package/src/scenarios/test-musicians/stack.json +1 -0
- package/src/scenarios/test-netflix/MIGRATION.md +23 -0
- package/src/scenarios/test-netflix/README.md +138 -0
- package/src/scenarios/test-netflix/actions.ts +92 -0
- package/src/scenarios/test-netflix/config.json +6 -0
- package/src/scenarios/test-netflix/data/categories.json +1 -0
- package/src/scenarios/test-netflix/data/companies.json +1 -0
- package/src/scenarios/test-netflix/data/credits.json +19797 -0
- package/src/scenarios/test-netflix/data/departments.json +18 -0
- package/src/scenarios/test-netflix/data/jobs.json +142 -0
- package/src/scenarios/test-netflix/data/movies.json +3497 -0
- package/src/scenarios/test-netflix/data/people.json +1 -0
- package/src/scenarios/test-netflix/data/synonyms.json +8 -0
- package/src/scenarios/test-netflix/data/users.json +70 -0
- package/src/scenarios/test-netflix/graph.json +1017 -0
- package/src/scenarios/test-netflix/queries.ts +159 -0
- package/src/scenarios/test-netflix/stack.json +14 -0
- package/src/schema/GraphBuilder.ts +106 -0
- package/src/schema/JsonSchemaExtractor.ts +107 -0
- package/src/schema/SchemaAnalyzer.ts +175 -0
- package/src/schema/SchemaExtractor.ts +102 -0
- package/src/schema/SynonymResolver.ts +143 -0
- package/src/scripts/dictionary.json +796 -0
- package/src/scripts/graph.json +664 -0
- package/src/scripts/regenerate.ts +248 -0
- package/src/types/index.ts +506 -0
package/src/api/Graph.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* api/Graph.ts — Niveau 0 : constructeur et surface unifiée
|
|
3
|
+
*
|
|
4
|
+
* Point d'entrée unique de LinkLab.
|
|
5
|
+
*
|
|
6
|
+
* const graph = new Graph(graphJson)
|
|
7
|
+
* const graph = new Graph(graphJson, { compiled, dataset })
|
|
8
|
+
*
|
|
9
|
+
* Expose :
|
|
10
|
+
* Niveau 2 — graph.from(a).to(b).path(strategy)
|
|
11
|
+
* Niveau 3 — graph.entities, graph.relations, graph.weights, graph.schema
|
|
12
|
+
* Niveau 4 — graph.compile(), graph.snapshot()
|
|
13
|
+
*
|
|
14
|
+
* Le niveau 1 (DomainProxy) sera ajouté dans graph.domain() — prochaine étape.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createRequire } from 'module'
|
|
18
|
+
import fs from 'fs'
|
|
19
|
+
import path from 'path'
|
|
20
|
+
import type {
|
|
21
|
+
Graph as GraphData,
|
|
22
|
+
CompiledGraph,
|
|
23
|
+
GraphNode,
|
|
24
|
+
GraphEdge,
|
|
25
|
+
Provider,
|
|
26
|
+
} from '../types/index.js'
|
|
27
|
+
import { GraphCompiler } from '../graph/GraphCompiler.js'
|
|
28
|
+
import { PathBuilder } from './PathBuilder.js'
|
|
29
|
+
import { createDomain } from './DomainNode.js'
|
|
30
|
+
import type { PathBuilderOptions } from './types.js'
|
|
31
|
+
|
|
32
|
+
export interface GraphOptions {
|
|
33
|
+
/** CompiledGraph précalculé — active .execute() sur PathBuilder */
|
|
34
|
+
compiled?: CompiledGraph
|
|
35
|
+
/** Dataset en mémoire { tableName: rows[] } — active .execute() */
|
|
36
|
+
dataset?: Record<string, any[]>
|
|
37
|
+
/** Provider externe (PostgreSQL, etc.) — active .execute() via SQL */
|
|
38
|
+
provider?: Provider
|
|
39
|
+
/** Préfixe de chemin pour résoudre les imports relatifs */
|
|
40
|
+
basePath?: string
|
|
41
|
+
/** Dictionnaire résolu — labels humains des routes */
|
|
42
|
+
dictionary?: Record<string, any> | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Lien navigable depuis un nœud — retourné par linksFrom() */
|
|
46
|
+
export interface NavigationLink {
|
|
47
|
+
to: string // nœud cible
|
|
48
|
+
label: string // nom de la relation ou vue sémantique
|
|
49
|
+
semantic: boolean // true = vue filtrée (actor, director...), false = table physique
|
|
50
|
+
weight?: number // poids courant
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class Graph {
|
|
54
|
+
private _data: GraphData
|
|
55
|
+
private _compiled: CompiledGraph | null
|
|
56
|
+
private _dataset: Record<string, any[]> | null
|
|
57
|
+
private _provider: Provider | null
|
|
58
|
+
private _dictionary: Record<string, any> | null
|
|
59
|
+
|
|
60
|
+
constructor(source: GraphData | string, options: GraphOptions = {}) {
|
|
61
|
+
// Accepte un objet GraphData directement ou un chemin vers graph.json
|
|
62
|
+
if (typeof source === 'string') {
|
|
63
|
+
const resolved = options.basePath
|
|
64
|
+
? path.resolve(options.basePath, source)
|
|
65
|
+
: path.resolve(source)
|
|
66
|
+
const req = createRequire(import.meta.url)
|
|
67
|
+
this._data = req(resolved) as GraphData
|
|
68
|
+
} else {
|
|
69
|
+
this._data = source
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this._compiled = options.compiled ?? null
|
|
73
|
+
this._dataset = options.dataset ?? null
|
|
74
|
+
this._provider = options.provider ?? null
|
|
75
|
+
this._dictionary = options.dictionary ?? null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Niveau 1 — Navigation sémantique (domain proxy) ─────────────────────────
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* domain() — retourne un Proxy sémantique sur le graphe.
|
|
82
|
+
* Optionnel — Graph lui-même est utilisable comme domaine directement.
|
|
83
|
+
*
|
|
84
|
+
* const cinema = new Graph(source, opts)
|
|
85
|
+
* await cinema.movies // via Graph comme domaine
|
|
86
|
+
* await cinema.domain().movies // équivalent explicite
|
|
87
|
+
* await cinema.domain('cinema').movies // avec nom (futur: permissions/projections)
|
|
88
|
+
*/
|
|
89
|
+
domain(_name?: string) {
|
|
90
|
+
return createDomain({
|
|
91
|
+
graphData: this._data,
|
|
92
|
+
compiled: this._compiled,
|
|
93
|
+
dataset: this._dataset,
|
|
94
|
+
provider: this._provider,
|
|
95
|
+
dictionary: this._dictionary,
|
|
96
|
+
}, this) // ← passe le Graph pour .graph
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Niveau 2 — Exploration algorithmique ──────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* from(node) — point de départ d'une traversée.
|
|
103
|
+
*
|
|
104
|
+
* graph.from('Pigalle').to('Alesia').path(Strategy.Comfort())
|
|
105
|
+
* graph.from('movies').to('people').execute({ id: 278 })
|
|
106
|
+
*/
|
|
107
|
+
from(node: string, opts: PathBuilderOptions = {}): PathBuilder {
|
|
108
|
+
return new PathBuilder(node, this._data, this._compiled, this._dataset ?? null, opts, this._provider)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* within(node, depth) — exploration radiale depuis un node.
|
|
113
|
+
* Retourne tous les nodes accessibles en ≤ depth sauts.
|
|
114
|
+
*
|
|
115
|
+
* graph.within('Châtelet', 3).nodes
|
|
116
|
+
*/
|
|
117
|
+
within(node: string, depth = 2): { nodes: GraphNode[] } {
|
|
118
|
+
const finder = new (require('../core/PathFinder.js').PathFinder)(this._data)
|
|
119
|
+
const reached = finder.getReachableNodes(node, depth)
|
|
120
|
+
const nodes = this._data.nodes.filter(n => reached.has(n.id))
|
|
121
|
+
return { nodes }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Niveau 3 — Introspection ───────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/** Liste des entités (nodes) du graphe */
|
|
127
|
+
get entities(): GraphNode[] {
|
|
128
|
+
return [...this._data.nodes]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Liste des relations (arêtes) du graphe */
|
|
132
|
+
get relations(): GraphEdge[] {
|
|
133
|
+
return [...this._data.edges]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Poids courants de toutes les arêtes — { edgeName: weight } */
|
|
137
|
+
get weights(): Record<string, number> {
|
|
138
|
+
return Object.fromEntries(
|
|
139
|
+
this._data.edges
|
|
140
|
+
.filter(e => e.name)
|
|
141
|
+
.map(e => [e.name!, Number(e.weight) || 1])
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Schéma résolu — nodes groupés par type */
|
|
146
|
+
get schema(): Record<string, GraphNode[]> {
|
|
147
|
+
const result: Record<string, GraphNode[]> = {}
|
|
148
|
+
for (const node of this._data.nodes) {
|
|
149
|
+
const type = (node as any).type ?? 'node'
|
|
150
|
+
if (!result[type]) result[type] = []
|
|
151
|
+
result[type].push(node)
|
|
152
|
+
}
|
|
153
|
+
return result
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* linksFrom(nodeId) — liens navigables depuis un nœud, au niveau sémantique maximal.
|
|
158
|
+
*
|
|
159
|
+
* Retourne les routes physiques ET les vues sémantiques du compiledGraph.
|
|
160
|
+
* Utilisé par : REPL (autocomplétion), TUI, extension VSCode.
|
|
161
|
+
*
|
|
162
|
+
* graph.linksFrom('movies')
|
|
163
|
+
* // → [
|
|
164
|
+
* // { to: 'people', label: 'people', semantic: false }, ← table physique
|
|
165
|
+
* // { to: 'people', label: 'actor', semantic: true }, ← vue filtrée jobId=1
|
|
166
|
+
* // { to: 'people', label: 'director', semantic: true }, ← vue filtrée jobId=2
|
|
167
|
+
* // ]
|
|
168
|
+
*/
|
|
169
|
+
linksFrom(nodeId: string): NavigationLink[] {
|
|
170
|
+
const links: NavigationLink[] = []
|
|
171
|
+
const seen = new Set<string>()
|
|
172
|
+
|
|
173
|
+
// Routes physiques — priorité : raw-graph edges, fallback : compiled.routes non-sémantiques
|
|
174
|
+
const rawEdges = this._data.edges.filter(e => e.from === nodeId)
|
|
175
|
+
if (rawEdges.length > 0) {
|
|
176
|
+
for (const e of rawEdges) {
|
|
177
|
+
const label = e.name ?? e.to
|
|
178
|
+
if (!seen.has(label)) {
|
|
179
|
+
seen.add(label)
|
|
180
|
+
links.push({ to: e.to, label, semantic: false, weight: Number(e.weight) || 1 })
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} else if (this._compiled) {
|
|
184
|
+
// Pas de raw-graph (cas loadGraph({ compiled })) → routes physiques depuis compiled
|
|
185
|
+
for (const r of (this._compiled as any).routes ?? []) {
|
|
186
|
+
if (r.from === nodeId && !r.semantic) {
|
|
187
|
+
const label = r.to
|
|
188
|
+
if (!seen.has(label)) {
|
|
189
|
+
seen.add(label)
|
|
190
|
+
links.push({ to: r.to, label, semantic: false, weight: r.primary?.weight })
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Routes sémantiques depuis le compiledGraph
|
|
197
|
+
if (this._compiled) {
|
|
198
|
+
for (const r of (this._compiled as any).routes ?? []) {
|
|
199
|
+
if (r.from === nodeId && r.semantic === true) {
|
|
200
|
+
const label = r.label ?? r.to
|
|
201
|
+
if (!seen.has(label)) {
|
|
202
|
+
seen.add(label)
|
|
203
|
+
links.push({ to: r.to, label, semantic: true, weight: r.primary?.weight })
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return links
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Niveau 4 — Maintenance ─────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* compile() — précalcule les routes optimales.
|
|
216
|
+
* Retourne un nouveau Graph avec le compiledGraph injecté.
|
|
217
|
+
*/
|
|
218
|
+
compile(config: Parameters<GraphCompiler['compile']>[1] = new Map()): Graph {
|
|
219
|
+
const compiler = new GraphCompiler()
|
|
220
|
+
const compiled = compiler.compile(this._data, config)
|
|
221
|
+
return new Graph(this._data, {
|
|
222
|
+
compiled,
|
|
223
|
+
dataset: this._dataset ?? undefined,
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* snapshot() — sérialise l'état courant (graph + compiled si présent).
|
|
229
|
+
*/
|
|
230
|
+
snapshot(): { graph: GraphData; compiled: CompiledGraph | null } {
|
|
231
|
+
return {
|
|
232
|
+
graph: this._data,
|
|
233
|
+
compiled: this._compiled,
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* weight(edgeName).set(value) — ajuste le poids d'une arête.
|
|
239
|
+
* Retourne un nouveau Graph (immuable).
|
|
240
|
+
*/
|
|
241
|
+
weight(edgeName: string) {
|
|
242
|
+
return {
|
|
243
|
+
set: (value: number): Graph => {
|
|
244
|
+
const edges = this._data.edges.map(e =>
|
|
245
|
+
e.name === edgeName ? { ...e, weight: value } : e
|
|
246
|
+
)
|
|
247
|
+
return new Graph(
|
|
248
|
+
{ ...this._data, edges },
|
|
249
|
+
{ compiled: this._compiled ?? undefined, dataset: this._dataset ?? undefined }
|
|
250
|
+
)
|
|
251
|
+
},
|
|
252
|
+
update: (fn: (current: number) => number): Graph => {
|
|
253
|
+
const edges = this._data.edges.map(e => {
|
|
254
|
+
if (e.name !== edgeName) return e
|
|
255
|
+
return { ...e, weight: fn(Number(e.weight) || 1) }
|
|
256
|
+
})
|
|
257
|
+
return new Graph(
|
|
258
|
+
{ ...this._data, edges },
|
|
259
|
+
{ compiled: this._compiled ?? undefined, dataset: this._dataset ?? undefined }
|
|
260
|
+
)
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Accès aux données brutes ───────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
/** GraphData interne — pour les couches qui en ont besoin */
|
|
268
|
+
get raw(): GraphData {
|
|
269
|
+
return this._data
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* api/PathBuilder.ts — Niveau 2 : exploration algorithmique
|
|
3
|
+
*
|
|
4
|
+
* Point d'entrée : graph.from(a).to(b)
|
|
5
|
+
*
|
|
6
|
+
* Surface publique :
|
|
7
|
+
* .path(strategy?) → meilleur chemin (PathResult)
|
|
8
|
+
* .paths(strategy?) → tous les chemins ordonnés (PathResult)
|
|
9
|
+
* .links → graphe de relations entre les deux nodes
|
|
10
|
+
* .execute(filters) → traversée avec hydratation de données
|
|
11
|
+
*
|
|
12
|
+
* Compile vers PathFinder (Dijkstra) + QueryEngine (données).
|
|
13
|
+
* Ne connaît pas le domaine — c'est le niveau 1 (DomainProxy) qui traduit
|
|
14
|
+
* les noms sémantiques en IDs de nodes avant d'appeler PathBuilder.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Graph, CompiledGraph, GraphEdge, Provider } from '../types/index.js'
|
|
18
|
+
import { PathFinder } from '../core/PathFinder.js'
|
|
19
|
+
import { QueryEngine } from '../runtime/QueryEngine.js'
|
|
20
|
+
import type {
|
|
21
|
+
Strategy,
|
|
22
|
+
PathResult,
|
|
23
|
+
ResolvedPath,
|
|
24
|
+
PathStep,
|
|
25
|
+
QueryResult,
|
|
26
|
+
PathBuilderOptions,
|
|
27
|
+
} from './types.js'
|
|
28
|
+
import { Strategy as S } from './types.js'
|
|
29
|
+
|
|
30
|
+
export class PathBuilder {
|
|
31
|
+
private _from: string
|
|
32
|
+
private _to: string | null = null
|
|
33
|
+
private _opts: PathBuilderOptions
|
|
34
|
+
private _graph: Graph
|
|
35
|
+
private _compiled: CompiledGraph | null
|
|
36
|
+
private _dataset: Record<string, any[]> | null
|
|
37
|
+
private _provider: Provider | null
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
from: string,
|
|
41
|
+
graph: Graph,
|
|
42
|
+
compiled: CompiledGraph | null = null,
|
|
43
|
+
dataset: Record<string, any[]> | null = null,
|
|
44
|
+
opts: PathBuilderOptions = {},
|
|
45
|
+
provider: Provider | null = null
|
|
46
|
+
) {
|
|
47
|
+
this._from = from
|
|
48
|
+
this._graph = graph
|
|
49
|
+
this._compiled = compiled
|
|
50
|
+
this._dataset = dataset
|
|
51
|
+
this._opts = opts
|
|
52
|
+
this._provider = provider
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Builder ────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
to(node: string): this {
|
|
58
|
+
this._to = node
|
|
59
|
+
return this
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
maxPaths(n: number): this {
|
|
63
|
+
this._opts = { ...this._opts, maxPaths: n }
|
|
64
|
+
return this
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
via(edgeTypes: string[]): this {
|
|
68
|
+
this._opts = { ...this._opts, via: edgeTypes }
|
|
69
|
+
return this
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
minHops(n: number): this {
|
|
73
|
+
this._opts = { ...this._opts, minHops: n }
|
|
74
|
+
return this
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Résultats ──────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* path(strategy?) — meilleur chemin selon la stratégie.
|
|
81
|
+
* Stratégie par défaut : Shortest (poids brut).
|
|
82
|
+
*
|
|
83
|
+
* metro: graph.from('Pigalle').to('Alesia').path(Strategy.Comfort())
|
|
84
|
+
* musicians: graph.from('Jackson').to('West').path()
|
|
85
|
+
*/
|
|
86
|
+
path(strategy?: Strategy): PathResult {
|
|
87
|
+
return this._findPaths(1, strategy)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* paths(strategy?) — tous les chemins ordonnés par poids.
|
|
92
|
+
*
|
|
93
|
+
* metro: graph.from('Chatelet').to('Nation').paths(Strategy.Shortest())
|
|
94
|
+
* musicians: graph.from('Pharrell').to('Kanye').paths()
|
|
95
|
+
*/
|
|
96
|
+
paths(strategy?: Strategy): PathResult {
|
|
97
|
+
const maxPaths = this._opts.maxPaths ?? 5
|
|
98
|
+
return this._findPaths(maxPaths, strategy)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* links — graphe de relations entre from et to.
|
|
103
|
+
* Retourne toutes les arêtes qui participent aux chemins possibles,
|
|
104
|
+
* sans les ordonner — vue structurelle, pas navigationnelle.
|
|
105
|
+
*
|
|
106
|
+
* musicians: graph.from('Jackson').to('West').links
|
|
107
|
+
*/
|
|
108
|
+
get links(): PathResult & { edges: GraphEdge[] } {
|
|
109
|
+
const result = this._findPaths(this._opts.maxPaths ?? 10)
|
|
110
|
+
// Collecter toutes les arêtes participant aux chemins trouvés
|
|
111
|
+
const nodeSet = new Set(result.paths.flatMap(p => p.nodes))
|
|
112
|
+
const edges = this._graph.edges.filter(
|
|
113
|
+
e => nodeSet.has(e.from) && nodeSet.has(e.to)
|
|
114
|
+
)
|
|
115
|
+
return { ...result, edges }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* execute(filters) — traversée avec hydratation de données.
|
|
120
|
+
* Uniquement disponible si un dataset ou provider est configuré.
|
|
121
|
+
*
|
|
122
|
+
* netflix: graph.from('movies').to('people').execute({ id: 278 })
|
|
123
|
+
* dvdrental: graph.from('customer').to('actor').execute({ id: 1 })
|
|
124
|
+
*/
|
|
125
|
+
async execute<T = Record<string, any>>(
|
|
126
|
+
filters: Record<string, any> = {}
|
|
127
|
+
): Promise<QueryResult<T>> {
|
|
128
|
+
if (!this._to) throw new Error(`PathBuilder : .to(node) requis avant execute()`)
|
|
129
|
+
const to = this._to
|
|
130
|
+
const start = Date.now()
|
|
131
|
+
|
|
132
|
+
if (!this._compiled || !this._dataset) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`execute() nécessite un compiledGraph et un dataset.\n` +
|
|
135
|
+
`Utilisez new Graph(graphJson, { compiled, dataset }) pour activer la traversée de données.`
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const engine = new QueryEngine(this._compiled)
|
|
140
|
+
const data = engine.executeInMemory(
|
|
141
|
+
{ from: this._from, to, filters },
|
|
142
|
+
this._dataset
|
|
143
|
+
) as T[]
|
|
144
|
+
|
|
145
|
+
let path: string[] = [this._from, to]
|
|
146
|
+
try {
|
|
147
|
+
const route = engine.getRoute(this._from, to)
|
|
148
|
+
path = route.primary.path
|
|
149
|
+
} catch { /* route inconnue — chemin direct */ }
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
from: this._from,
|
|
153
|
+
to,
|
|
154
|
+
filters,
|
|
155
|
+
data,
|
|
156
|
+
path,
|
|
157
|
+
timing: Date.now() - start,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Interne ────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
private _assertTo(): void {
|
|
164
|
+
if (!this._to) throw new Error(`PathBuilder : .to(node) requis avant cette opération`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private _findPaths(maxPaths: number, strategy?: Strategy): PathResult {
|
|
168
|
+
if (!this._to) throw new Error(`PathBuilder : .to(node) requis avant path()/paths()`)
|
|
169
|
+
const to = this._to
|
|
170
|
+
|
|
171
|
+
const penalty = S.toPenalty(strategy ?? this._opts.strategy ?? S.Shortest())
|
|
172
|
+
const finder = new PathFinder(this._graph)
|
|
173
|
+
const rawPaths = finder.findAllPaths(
|
|
174
|
+
this._from,
|
|
175
|
+
to,
|
|
176
|
+
maxPaths,
|
|
177
|
+
50,
|
|
178
|
+
penalty,
|
|
179
|
+
this._opts.via,
|
|
180
|
+
this._opts.minHops ?? 0
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
if (rawPaths.length === 0) {
|
|
184
|
+
return { from: this._from, to, found: false, paths: [] }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const paths: ResolvedPath[] = rawPaths.map(nodes => ({
|
|
188
|
+
nodes,
|
|
189
|
+
steps: this._resolveSteps(nodes),
|
|
190
|
+
weight: this._computeWeight(nodes, penalty),
|
|
191
|
+
hops: nodes.length - 1,
|
|
192
|
+
}))
|
|
193
|
+
|
|
194
|
+
return { from: this._from, to, found: true, paths }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Enrichit les nodes avec labels et arêtes empruntées.
|
|
199
|
+
*/
|
|
200
|
+
private _resolveSteps(nodes: string[]): PathStep[] {
|
|
201
|
+
const nodeMap = new Map(this._graph.nodes.map(n => [n.id, n]))
|
|
202
|
+
|
|
203
|
+
return nodes.map((nodeId, i) => {
|
|
204
|
+
const node = nodeMap.get(nodeId)
|
|
205
|
+
const step: PathStep = {
|
|
206
|
+
node: nodeId,
|
|
207
|
+
label: (node as any)?.label ?? (node as any)?.name ?? nodeId,
|
|
208
|
+
}
|
|
209
|
+
if (i > 0) {
|
|
210
|
+
// Arête qui mène à ce node depuis le précédent
|
|
211
|
+
step.via = this._graph.edges.find(
|
|
212
|
+
e => e.from === nodes[i - 1] && e.to === nodeId
|
|
213
|
+
) ?? this._graph.edges.find(
|
|
214
|
+
e => e.from === nodeId && e.to === nodes[i - 1]
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
return step
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Calcule le poids total d'un chemin en tenant compte de la pénalité
|
|
223
|
+
* de correspondance (changement de ligne/type d'arête).
|
|
224
|
+
*/
|
|
225
|
+
private _computeWeight(nodes: string[], transferPenalty: number): number {
|
|
226
|
+
let weight = 0
|
|
227
|
+
let prevEdgeType: string | undefined
|
|
228
|
+
|
|
229
|
+
for (let i = 0; i < nodes.length - 1; i++) {
|
|
230
|
+
const edge = this._graph.edges.find(
|
|
231
|
+
e => e.from === nodes[i] && e.to === nodes[i + 1]
|
|
232
|
+
)
|
|
233
|
+
const w = edge ? Number(edge.weight) || 1 : 1
|
|
234
|
+
const type = edge?.metadata?.type ?? edge?.via
|
|
235
|
+
|
|
236
|
+
// Pénalité si changement de type d'arête (correspondance)
|
|
237
|
+
if (transferPenalty > 0 && prevEdgeType && type !== prevEdgeType) {
|
|
238
|
+
weight += transferPenalty
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
weight += w
|
|
242
|
+
prevEdgeType = type
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return weight
|
|
246
|
+
}
|
|
247
|
+
}
|
package/src/api/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* api/index.ts — Re-exports du dossier api/
|
|
3
|
+
*/
|
|
4
|
+
export { Graph } from './Graph.js'
|
|
5
|
+
export { PathBuilder } from './PathBuilder.js'
|
|
6
|
+
export {
|
|
7
|
+
Strategy,
|
|
8
|
+
} from './types.js'
|
|
9
|
+
export type {
|
|
10
|
+
PathResult,
|
|
11
|
+
ResolvedPath,
|
|
12
|
+
PathStep,
|
|
13
|
+
QueryResult,
|
|
14
|
+
PathBuilderOptions,
|
|
15
|
+
} from './types.js'
|