@yanqirenshi/d3.sql 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/.babelrc +14 -0
- package/README.md +64 -0
- package/dist/components/D3Sql.js +538 -0
- package/dist/index.js +4 -0
- package/dist/js/SqlModel.js +717 -0
- package/jest.config.cjs +21 -0
- package/package.json +47 -0
- package/src/components/D3Sql.js +356 -0
- package/src/index.js +5 -0
- package/src/js/SqlModel.js +601 -0
- package/tests/SqlModel.test.js +135 -0
- package/tests/d3-dag-stub.js +4 -0
- package/tests/d3-stub.js +19 -0
package/jest.config.cjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
// テストファイルは tests ディレクトリに指定
|
|
3
|
+
testMatch: [
|
|
4
|
+
"**/tests/**/?(*.)+(spec|test).[tj]s?(x)"
|
|
5
|
+
],
|
|
6
|
+
// monorepo では babel-jest がルートに hoist されるため名前解決に任せる
|
|
7
|
+
transform: {
|
|
8
|
+
"^.+\\.js$": 'babel-jest',
|
|
9
|
+
},
|
|
10
|
+
// d3 v7 / d3-dag v1 は ESM-only で CJS ランタイムの jest では読めないため、
|
|
11
|
+
// スタブにマップする(テスト対象のデータ層は d3-dag を使う自動レイアウト
|
|
12
|
+
// 以外 d3 の関数を呼ばない。d3-dag 部分はブラウザ検証で担保する)
|
|
13
|
+
moduleNameMapper: {
|
|
14
|
+
'^d3$': '<rootDir>/tests/d3-stub.js',
|
|
15
|
+
'^d3-dag$': '<rootDir>/tests/d3-dag-stub.js',
|
|
16
|
+
},
|
|
17
|
+
collectCoverageFrom: [
|
|
18
|
+
"**/src/**/*.js",
|
|
19
|
+
"!**/node_modules/**",
|
|
20
|
+
],
|
|
21
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yanqirenshi/d3.sql",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SQL lineage graph ({entities, lineageEdges, joinEdges}) rendered as an interactive, pannable/zoomable SVG diagram",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"module": "dist/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"transpile": "babel src -d dist --copy-files",
|
|
15
|
+
"prepublishOnly": "npm run transpile",
|
|
16
|
+
"test": "jest"
|
|
17
|
+
},
|
|
18
|
+
"author": "yanqirenshi@gmail.com",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"keywords": [
|
|
21
|
+
"d3",
|
|
22
|
+
"sql",
|
|
23
|
+
"lineage",
|
|
24
|
+
"graph",
|
|
25
|
+
"react",
|
|
26
|
+
"visualization"
|
|
27
|
+
],
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
30
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"d3": "^7.9.0",
|
|
34
|
+
"d3-dag": "^1.2.2"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@babel/cli": "^7.12.10",
|
|
38
|
+
"@babel/core": "^7.12.10",
|
|
39
|
+
"@babel/preset-env": "^7.12.11",
|
|
40
|
+
"@babel/preset-react": "^7.12.10",
|
|
41
|
+
"jest": "^25.2.4",
|
|
42
|
+
"babel-jest": "^25.2.4"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// D3Sql — SQL リネージグラフの React ラッパ(描画側)。
|
|
2
|
+
// データの正規化・レイアウト・線の幾何は SqlModel(データクラス)が持ち、
|
|
3
|
+
// ここでは model の結果を SVG に写すことに徹する。
|
|
4
|
+
// パン/ズームは d3.zoom に任せ(カーソル位置基準のズーム計算とイベント
|
|
5
|
+
// 付与)、React はその結果(transform)を <g> の translate/scale として
|
|
6
|
+
// 描画するだけ(DOM 操作自体は d3 に持たせない)。
|
|
7
|
+
|
|
8
|
+
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
9
|
+
|
|
10
|
+
import * as d3 from 'd3';
|
|
11
|
+
|
|
12
|
+
import SqlModel, {
|
|
13
|
+
HEADER_H,
|
|
14
|
+
ROW_H,
|
|
15
|
+
BRANCH_GAP,
|
|
16
|
+
DISTINCT_BAND_WIDTH,
|
|
17
|
+
} from '../js/SqlModel.js';
|
|
18
|
+
|
|
19
|
+
const ZOOM_MIN = 0.2;
|
|
20
|
+
const ZOOM_MAX = 4;
|
|
21
|
+
const INITIAL_PADDING = 40;
|
|
22
|
+
|
|
23
|
+
// select句の線のラベルboxをクリックした時のポップアップ内容。内容は
|
|
24
|
+
// 単純なテキストなので通常のReact/HTMLで表示する。
|
|
25
|
+
function LineageLabelPopup ({ info, onClose }) {
|
|
26
|
+
if (!info) return null;
|
|
27
|
+
return (
|
|
28
|
+
<div onClick={onClose}
|
|
29
|
+
style={{
|
|
30
|
+
position: 'fixed', inset: 0, zIndex: 1000,
|
|
31
|
+
background: 'rgba(0,0,0,0.35)',
|
|
32
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
33
|
+
}}>
|
|
34
|
+
<div onClick={(e) => e.stopPropagation()}
|
|
35
|
+
style={{
|
|
36
|
+
background: '#ffffff', borderRadius: 6, padding: '16px 20px',
|
|
37
|
+
minWidth: 280, maxWidth: 420,
|
|
38
|
+
boxShadow: '0 4px 24px rgba(0,0,0,0.3)',
|
|
39
|
+
}}>
|
|
40
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
|
41
|
+
<h3 style={{ margin: 0, fontSize: 16 }}>{info.label}</h3>
|
|
42
|
+
<button onClick={onClose}
|
|
43
|
+
style={{ border: 'none', background: 'none', fontSize: 18, cursor: 'pointer', lineHeight: 1 }}>
|
|
44
|
+
×
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|
|
47
|
+
<table style={{ marginTop: 12, width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
|
48
|
+
<tbody>
|
|
49
|
+
<tr>
|
|
50
|
+
<td style={{ padding: '4px 6px', color: '#999999' }}>変換元</td>
|
|
51
|
+
<td style={{ padding: '4px 6px' }}>
|
|
52
|
+
{info.fromDisplay || (info.fromTitle ? `${info.fromTitle}.${info.fromColumn}` : 'リテラル値/由来なし')}
|
|
53
|
+
</td>
|
|
54
|
+
</tr>
|
|
55
|
+
<tr>
|
|
56
|
+
<td style={{ padding: '4px 6px', color: '#999999' }}>変換先</td>
|
|
57
|
+
<td style={{ padding: '4px 6px' }}>{info.toTitle}.{info.toColumn}</td>
|
|
58
|
+
</tr>
|
|
59
|
+
</tbody>
|
|
60
|
+
</table>
|
|
61
|
+
{info.sql && (
|
|
62
|
+
<pre style={{
|
|
63
|
+
marginTop: 12, padding: '8px 10px', background: '#f5f5f5',
|
|
64
|
+
borderRadius: 4, fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
|
|
65
|
+
}}>
|
|
66
|
+
{info.sql}
|
|
67
|
+
</pre>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 「Result N」(番号付き、中間サブクエリの出力)は通常の太さ、それ以外
|
|
75
|
+
// (実テーブル名や番号無しの最終「Result」)は太字のままにする。
|
|
76
|
+
const RESULT_N_TITLE_RE = /^Result \d+$/;
|
|
77
|
+
|
|
78
|
+
// エンティティ1件の箱(ヘッダ・列行・DISTINCT帯・ORDER BY番号)を描く。
|
|
79
|
+
function EntityBox ({ box }) {
|
|
80
|
+
const titleWeight = RESULT_N_TITLE_RE.test(box.title) ? 'normal' : 'bold';
|
|
81
|
+
return (
|
|
82
|
+
<g>
|
|
83
|
+
{box.alias && (
|
|
84
|
+
<text x={box.x} y={box.y - 8} fontSize={12} fill="#666666">
|
|
85
|
+
{box.alias}
|
|
86
|
+
</text>
|
|
87
|
+
)}
|
|
88
|
+
<rect x={box.x} y={box.y} width={box.w} height={box.h}
|
|
89
|
+
fill="#ffffff" stroke="#333333" strokeWidth={1}/>
|
|
90
|
+
<rect x={box.x} y={box.y} width={box.w} height={HEADER_H}
|
|
91
|
+
fill={box.headerColor || '#e8e8e8'} stroke="#333333" strokeWidth={1}/>
|
|
92
|
+
<text x={box.x + box.w / 2} y={box.y + HEADER_H / 2}
|
|
93
|
+
textAnchor="middle" dominantBaseline="central"
|
|
94
|
+
fontWeight={titleWeight} fontSize={14} fill={box.headerTextColor || '#000000'}>
|
|
95
|
+
{box.title}
|
|
96
|
+
</text>
|
|
97
|
+
{box.rows.map((row, i) => (
|
|
98
|
+
<g key={row.name}>
|
|
99
|
+
{i > 0 && (
|
|
100
|
+
<line x1={box.x} x2={box.x + box.w}
|
|
101
|
+
y1={box.y + HEADER_H + i * ROW_H} y2={box.y + HEADER_H + i * ROW_H}
|
|
102
|
+
stroke="#cccccc" strokeWidth={1}/>
|
|
103
|
+
)}
|
|
104
|
+
<text x={box.x + 10} y={row.y}
|
|
105
|
+
dominantBaseline="central" fontSize={13}>
|
|
106
|
+
{row.name}
|
|
107
|
+
</text>
|
|
108
|
+
</g>
|
|
109
|
+
))}
|
|
110
|
+
{box.distinct && (
|
|
111
|
+
<g>
|
|
112
|
+
{/* ヘッダ部分は列(行)の範囲外なので、帯の開始位置は最初の
|
|
113
|
+
列の開始位置(ヘッダの下)に揃える。 */}
|
|
114
|
+
<rect x={box.x + box.w + BRANCH_GAP} y={box.y + HEADER_H}
|
|
115
|
+
width={DISTINCT_BAND_WIDTH} height={box.h - HEADER_H}
|
|
116
|
+
fill="#ffffff" stroke="#333333" strokeWidth={1}/>
|
|
117
|
+
<text x={box.x + box.w + BRANCH_GAP + DISTINCT_BAND_WIDTH / 2}
|
|
118
|
+
y={box.y + HEADER_H + (box.h - HEADER_H) / 2}
|
|
119
|
+
textAnchor="middle" dominantBaseline="central" fontSize={11} fill="#666666"
|
|
120
|
+
transform={`rotate(-90 ${box.x + box.w + BRANCH_GAP + DISTINCT_BAND_WIDTH / 2} ${box.y + HEADER_H + (box.h - HEADER_H) / 2})`}>
|
|
121
|
+
{box.bandLabel}
|
|
122
|
+
</text>
|
|
123
|
+
</g>
|
|
124
|
+
)}
|
|
125
|
+
{box.orderByColumns && box.rows.map((row) => {
|
|
126
|
+
const seq = box.orderByColumns.indexOf(row.name);
|
|
127
|
+
if (seq === -1) return null;
|
|
128
|
+
// ORDER BYのキー列は、その行内・箱の右端寄りに順序番号のbox
|
|
129
|
+
// を付ける。box自体の見た目(fontSize/高さ)はリネージ線の
|
|
130
|
+
// ラベルbox(LineageLabelBox)と揃える。
|
|
131
|
+
const width = 20;
|
|
132
|
+
const height = 16;
|
|
133
|
+
const rightMargin = 6;
|
|
134
|
+
const cx = box.x + box.w - rightMargin - width / 2;
|
|
135
|
+
return (
|
|
136
|
+
<g key={`orderby:${row.name}`}>
|
|
137
|
+
<rect x={cx - width / 2} y={row.y - height / 2} width={width} height={height}
|
|
138
|
+
fill="#ffffff" stroke="#999999" strokeWidth={1}/>
|
|
139
|
+
<text x={cx} y={row.y} textAnchor="middle" dominantBaseline="central"
|
|
140
|
+
fontSize={11} fill="#333333">
|
|
141
|
+
{seq + 1}
|
|
142
|
+
</text>
|
|
143
|
+
</g>
|
|
144
|
+
);
|
|
145
|
+
})}
|
|
146
|
+
</g>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ラベルboxの幅は、描画してみないと正確な文字幅が分からない(全角/半角の
|
|
151
|
+
// 混在や実際のフォントメトリクスに依存するため)。SVGの<text>は描画後に
|
|
152
|
+
// `getComputedTextLength()`で実測できるので、初期描画は仮の幅で出し、
|
|
153
|
+
// レイアウト確定後(ペイント前)に実測値へ差し替える。
|
|
154
|
+
// `sources`({x,y}の配列)が渡された場合は、box右辺からそれぞれへ斜めの
|
|
155
|
+
// 線を引く。複数列を1つの式(例: ||連結)で結合した場合は、1つのラベル
|
|
156
|
+
// boxから複数本の斜め線が出る形になる。
|
|
157
|
+
function LineageLabelBox ({ x, y, label, isGroupBy, sources, onClick }) {
|
|
158
|
+
const textRef = useRef(null);
|
|
159
|
+
const [width, setWidth] = useState(label.length * 10 + 8);
|
|
160
|
+
|
|
161
|
+
useLayoutEffect(() => {
|
|
162
|
+
if (textRef.current) {
|
|
163
|
+
setWidth(textRef.current.getComputedTextLength() + 8);
|
|
164
|
+
}
|
|
165
|
+
}, [label]);
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<>
|
|
169
|
+
{sources && sources.map((s, i) => (
|
|
170
|
+
<line key={i} x1={x + width} y1={y} x2={s.x} y2={s.y} stroke="#666666" strokeWidth={1.5}/>
|
|
171
|
+
))}
|
|
172
|
+
<g onClick={onClick} style={{ cursor: onClick ? 'pointer' : 'default' }}>
|
|
173
|
+
<rect x={x} y={y - 8} width={width} height={16} fill="#ffffff"
|
|
174
|
+
stroke={isGroupBy ? 'none' : '#999999'} strokeWidth={1}/>
|
|
175
|
+
<text ref={textRef} x={x + 4} y={y} textAnchor="start" dominantBaseline="central"
|
|
176
|
+
fontSize={11} fill="#333333">
|
|
177
|
+
{label}
|
|
178
|
+
</text>
|
|
179
|
+
</g>
|
|
180
|
+
</>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// リネージ矢印: 常にsource(右)からResult側(左)へ向く。ラベルが無い
|
|
185
|
+
// (単純な値渡し)場合はsourceからdestinationへの直線1本、sourceの左辺に
|
|
186
|
+
// ポート(小さい丸)、destination側に矢印。ラベルがある場合(GROUP BYキー
|
|
187
|
+
// 番号や集約/関数名)は、ラベルboxを境にResult側は水平な矢印、source側は
|
|
188
|
+
// 斜めの線という2本の折れ線にする。
|
|
189
|
+
// GROUP BYキー番号(例: G01)は枠線無し、集約/関数名などその他のラベルは
|
|
190
|
+
// 枠線付きのboxにする。
|
|
191
|
+
// リテラル値・列を取らない関数呼び出し(由来となる実テーブルが無い列)は
|
|
192
|
+
// `from`が無いエッジとして表し、ラベルboxからResultへの水平な矢印だけを
|
|
193
|
+
// 描く(ポートの丸・source側の線は無し)。
|
|
194
|
+
const GROUP_BY_LABEL_RE = /^G\d+$/;
|
|
195
|
+
|
|
196
|
+
function LineageEdge ({ x1, y1, x2, y2, sources, label, labelX, labelInfo, onLabelClick, showPort = true }) {
|
|
197
|
+
const lx = labelX !== undefined ? labelX : (x1 + x2) / 2;
|
|
198
|
+
const isGroupBy = label && GROUP_BY_LABEL_RE.test(label);
|
|
199
|
+
|
|
200
|
+
if (!label) {
|
|
201
|
+
return (
|
|
202
|
+
<g>
|
|
203
|
+
<line x1={x1} y1={y1} x2={x2} y2={y2}
|
|
204
|
+
stroke="#666666" strokeWidth={1.5} markerEnd="url(#d3-sql-arrow)"/>
|
|
205
|
+
{showPort && <circle cx={x1} cy={y1} r={3} fill="#666666"/>}
|
|
206
|
+
</g>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// `sources`(複数列を1つの式で結合した場合の由来一覧)が無ければ、
|
|
211
|
+
// 単一source(x1,y1)を1件だけの配列として扱う。
|
|
212
|
+
const sourcePoints = sources || (showPort ? [{ x: x1, y: y1 }] : []);
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<g>
|
|
216
|
+
<line x1={lx} y1={y2} x2={x2} y2={y2}
|
|
217
|
+
stroke="#666666" strokeWidth={1.5} markerEnd="url(#d3-sql-arrow)"/>
|
|
218
|
+
{sourcePoints.map((p, i) => <circle key={i} cx={p.x} cy={p.y} r={3} fill="#666666"/>)}
|
|
219
|
+
<LineageLabelBox
|
|
220
|
+
x={lx} y={y2} label={label} isGroupBy={isGroupBy}
|
|
221
|
+
sources={sourcePoints}
|
|
222
|
+
onClick={() => onLabelClick && onLabelClick(labelInfo)}
|
|
223
|
+
/>
|
|
224
|
+
</g>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// JOIN線・IN線用の破線パターン(JOINとINは間隔を変えて見分けられるように
|
|
229
|
+
// する)。"線の長さ 隙間の長さ"の順。
|
|
230
|
+
const JOIN_DASH = '8 5';
|
|
231
|
+
const IN_DASH = '3 3';
|
|
232
|
+
|
|
233
|
+
// JOIN条件の線: データの変換ではなく「一致させる列同士」なので、aの左辺から
|
|
234
|
+
// 一度左に出てから下へ、bの左辺へ横に入るエルボー型の折れ線で描く。
|
|
235
|
+
// 破線にすることでリネージ矢印(直線・実線)と区別する。
|
|
236
|
+
// 矢印の向きはJOIN種別を表す: LEFT=b側のみ矢印、RIGHT=a側のみ矢印、
|
|
237
|
+
// INNER=矢印無し。
|
|
238
|
+
// `WHERE col IN (サブクエリ)`のような、FROM句のJOINではない「列の一致」も
|
|
239
|
+
// 同じ形の線で描くが、JOINと見分けられるよう`label`(例:"IN")と、破線の間隔
|
|
240
|
+
// (`kind:'in'`のときはJOINより細かい間隔)で区別する。
|
|
241
|
+
// エルボーの4点を通る折れ線のd属性はd3のline generatorで作る。
|
|
242
|
+
const elbowLine = d3.line();
|
|
243
|
+
|
|
244
|
+
function JoinEdge ({ x1, y1, x2, y2, joinType, branchX, label, kind }) {
|
|
245
|
+
const d = elbowLine([[x1, y1], [branchX, y1], [branchX, y2], [x2, y2]]);
|
|
246
|
+
const dashArray = kind === 'in' ? IN_DASH : JOIN_DASH;
|
|
247
|
+
const markerStart = joinType === 'RIGHT' ? 'url(#d3-sql-arrow)' : undefined;
|
|
248
|
+
const markerEnd = joinType === 'LEFT' ? 'url(#d3-sql-arrow)' : undefined;
|
|
249
|
+
const my = (y1 + y2) / 2;
|
|
250
|
+
return (
|
|
251
|
+
<g>
|
|
252
|
+
<path d={d} fill="none" stroke="#999999" strokeWidth={1.5} strokeDasharray={dashArray}
|
|
253
|
+
markerStart={markerStart} markerEnd={markerEnd}/>
|
|
254
|
+
{label && (
|
|
255
|
+
<g>
|
|
256
|
+
<rect x={branchX - 12} y={my - 9} width={24} height={16} fill="#ffffff"/>
|
|
257
|
+
<text x={branchX} y={my} textAnchor="middle" dominantBaseline="central"
|
|
258
|
+
fontSize={11} fill="#333333">
|
|
259
|
+
{label}
|
|
260
|
+
</text>
|
|
261
|
+
</g>
|
|
262
|
+
)}
|
|
263
|
+
</g>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// マウスホイールでのズームとドラッグでの視点移動をd3.zoomに任せるフック。
|
|
268
|
+
// `initialTransform`({x,y,k})が渡された場合、マウント時にd3.zoom自身の
|
|
269
|
+
// 内部状態もそれで初期化する(d3の内部状態も合わせないと、最初の操作で
|
|
270
|
+
// transformが(0,0,1)に巻き戻ってしまう)。
|
|
271
|
+
function usePanZoom (initialTransform) {
|
|
272
|
+
const containerRef = useRef(null);
|
|
273
|
+
const [transform, setTransform] = useState(initialTransform || { x: 0, y: 0, k: 1 });
|
|
274
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
275
|
+
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
const el = containerRef.current;
|
|
278
|
+
if (!el) return;
|
|
279
|
+
|
|
280
|
+
const zoomBehavior = d3.zoom()
|
|
281
|
+
.scaleExtent([ZOOM_MIN, ZOOM_MAX])
|
|
282
|
+
.on('start', () => setIsDragging(true))
|
|
283
|
+
.on('zoom', (event) => setTransform({ x: event.transform.x, y: event.transform.y, k: event.transform.k }))
|
|
284
|
+
.on('end', () => setIsDragging(false));
|
|
285
|
+
|
|
286
|
+
const selection = d3.select(el);
|
|
287
|
+
selection.call(zoomBehavior);
|
|
288
|
+
|
|
289
|
+
if (initialTransform) {
|
|
290
|
+
zoomBehavior.transform(
|
|
291
|
+
selection,
|
|
292
|
+
d3.zoomIdentity.translate(initialTransform.x, initialTransform.y).scale(initialTransform.k),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return () => selection.on('.zoom', null);
|
|
297
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- 初期transformは
|
|
298
|
+
// マウント時に1回だけ適用する(依存に入れるとズーム操作のたびに戻ってしまう)。
|
|
299
|
+
}, []);
|
|
300
|
+
|
|
301
|
+
return { containerRef, transform, isDragging };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export default function D3Sql ({ data }) {
|
|
305
|
+
// 正規化・レイアウト・線の幾何はデータクラス(SqlModel)に任せる。
|
|
306
|
+
const model = useMemo(() => new SqlModel(data), [data]);
|
|
307
|
+
const { containerRef, transform, isDragging } = usePanZoom(model.initialTransform(INITIAL_PADDING));
|
|
308
|
+
const [selectedLabel, setSelectedLabel] = useState(null);
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<div ref={containerRef}
|
|
312
|
+
style={{
|
|
313
|
+
width: '100%',
|
|
314
|
+
height: '100%',
|
|
315
|
+
overflow: 'hidden',
|
|
316
|
+
cursor: isDragging ? 'grabbing' : 'grab',
|
|
317
|
+
userSelect: 'none',
|
|
318
|
+
}}>
|
|
319
|
+
<svg width="100%" height="100%">
|
|
320
|
+
<defs>
|
|
321
|
+
<marker id="d3-sql-arrow" viewBox="0 0 10 10" refX="9" refY="5"
|
|
322
|
+
markerWidth={7} markerHeight={7} orient="auto-start-reverse">
|
|
323
|
+
<path d="M 0 0 L 10 5 L 0 10 z" fill="#666666"/>
|
|
324
|
+
</marker>
|
|
325
|
+
</defs>
|
|
326
|
+
<g transform={`translate(${transform.x},${transform.y}) scale(${transform.k})`}>
|
|
327
|
+
{model.joinLines.map(({ key, ...edge }) => <JoinEdge key={key} {...edge}/>)}
|
|
328
|
+
{model.lineageLines.map(({ key, ...edge }) => (
|
|
329
|
+
<LineageEdge key={key} {...edge} onLabelClick={setSelectedLabel}/>
|
|
330
|
+
))}
|
|
331
|
+
{model.boxList().map((box) => <EntityBox key={box.id} box={box}/>)}
|
|
332
|
+
{model.boxList().filter((box) => box.whereText).map((box) => {
|
|
333
|
+
// WHERE/ORDER BYなど、そのエンティティのSELECTスコープに
|
|
334
|
+
// 付随する句のテキストは、そのエンティティ自身のxに揃え、
|
|
335
|
+
// その箱の下端から22px下に配置する(サブクエリごとに別々の
|
|
336
|
+
// WHEREを持つ場合は、それぞれのResult/実テーブルの直下に
|
|
337
|
+
// 表示する)。
|
|
338
|
+
const textX = box.x;
|
|
339
|
+
const textY = box.y + box.h + 22;
|
|
340
|
+
return (
|
|
341
|
+
<text key={`clause:${box.id}`} x={textX} y={textY} fontFamily="monospace"
|
|
342
|
+
fontSize={13} fill="#333333" xmlSpace="preserve">
|
|
343
|
+
{box.whereText.split('\n').map((line, i) => (
|
|
344
|
+
<tspan key={i} x={textX} dy={i === 0 ? 0 : 18}>
|
|
345
|
+
{line}
|
|
346
|
+
</tspan>
|
|
347
|
+
))}
|
|
348
|
+
</text>
|
|
349
|
+
);
|
|
350
|
+
})}
|
|
351
|
+
</g>
|
|
352
|
+
</svg>
|
|
353
|
+
<LineageLabelPopup info={selectedLabel} onClose={() => setSelectedLabel(null)}/>
|
|
354
|
+
</div>
|
|
355
|
+
);
|
|
356
|
+
}
|