@yanqirenshi/model.deployment 0.0.0-stage → 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/src/patch.ts ADDED
@@ -0,0 +1,317 @@
1
+ // UI の操作(ドラッグ・インスペクタの適用)を、保存する patch(JSON Merge Patch)に変換する。
2
+ //
3
+ // 戻り値は { collection, id, patch }。利用側(iHub など)は図の種類を知らずにこれを保存すればよい。
4
+ // 入力の検証に失敗したときは ModelError を投げる(メッセージは利用者にそのまま見せられる日本語)。
5
+ // model.sitemap の patch.ts を元にしている(ラベルは label.text、種類は NODE / COMPONENT、線に矢印の有無がある)。
6
+
7
+ import { NODE_TYPES, isEdge, withDefaults } from './defaults.js';
8
+ import { fieldsOf } from './fields.js';
9
+ import type {
10
+ MergePatch, DeploymentChange, DeploymentCollection, DeploymentEdgeRecord, DeploymentMove, DeploymentNodeRecord,
11
+ DeploymentRecord, DeploymentValues,
12
+ } from './types.js';
13
+
14
+ /** 入力の検証エラー。message は利用者向け */
15
+ export class ModelError extends Error {
16
+ constructor(message: string) {
17
+ super(message);
18
+ this.name = 'ModelError';
19
+ }
20
+ }
21
+
22
+ /** レコードを置くコレクション */
23
+ export function collectionOf(record: DeploymentRecord): DeploymentCollection {
24
+ return isEdge(record) ? 'edges' : 'nodes';
25
+ }
26
+
27
+ /** インスペクタの見出し。ラベルの文字、無ければ「種類 id」(線は「接続元 → 接続先」) */
28
+ export function titleOf(record: DeploymentRecord): string {
29
+ if (isEdge(record))
30
+ return `${String(record.from.id)} → ${String(record.to.id)}`;
31
+
32
+ const text = record.label && typeof record.label === 'object' ? record.label.text : null;
33
+
34
+ if (typeof text === 'string' && text !== '')
35
+ return text;
36
+
37
+ return `${record.type || ''} ${String(record.id)}`.trim();
38
+ }
39
+
40
+ /** ドット区切りの path で値を取り出す */
41
+ export function getByPath(obj: unknown, path: string): unknown {
42
+ return path.split('.').reduce<unknown>(
43
+ (acc, key) => (acc == null ? acc : (acc as Record<string, unknown>)[key]),
44
+ obj,
45
+ );
46
+ }
47
+
48
+ /**
49
+ * JSON Merge Patch をレコードに重ねたものを返す(入力は書き換えない)。
50
+ * null の項目は除く。保存したあと、表示を先に更新するときに使う。
51
+ */
52
+ export function mergePatch<T extends object>(record: T, patch: MergePatch): T {
53
+ const out: Record<string, unknown> = { ...(record as Record<string, unknown>) };
54
+
55
+ for (const [key, value] of Object.entries(patch)) {
56
+ if (value === null) {
57
+ delete out[key];
58
+ } else if (typeof value === 'object' && !Array.isArray(value)) {
59
+ const base = out[key];
60
+
61
+ out[key] = mergePatch(
62
+ base && typeof base === 'object' && !Array.isArray(base) ? (base as object) : {},
63
+ value as MergePatch,
64
+ );
65
+ } else {
66
+ out[key] = value;
67
+ }
68
+ }
69
+
70
+ return out as T;
71
+ }
72
+
73
+ /**
74
+ * ドラッグで動かしたノードの位置を保存する patch。
75
+ * 保存してある position に移動量を足し、整数に丸める(position が無いときは 0, 0 から)。
76
+ * 子のノード(COMPONENT を含む)の position は親の中での位置なので、親を動かしても子の patch は要らない。
77
+ */
78
+ export function movePatch(node: DeploymentNodeRecord, move: DeploymentMove): DeploymentChange {
79
+ const base = node.position || { x: 0, y: 0 };
80
+
81
+ return {
82
+ collection: 'nodes',
83
+ id: node.id,
84
+ patch: {
85
+ position: {
86
+ x: Math.round(base.x + move.dx),
87
+ y: Math.round(base.y + move.dy),
88
+ },
89
+ },
90
+ };
91
+ }
92
+
93
+ // ---- 入力の読み取り ------------------------------------------------------------
94
+
95
+ function toNumber(value: string): number | null {
96
+ const n = Number(value);
97
+
98
+ return value !== '' && Number.isFinite(n) ? n : null;
99
+ }
100
+
101
+ /**
102
+ * 色として読める文字列か。
103
+ * ブラウザでは CSS.supports('color', v) で判定する。Node など CSS の無い環境では、
104
+ * #rgb / #rrggbb / #rrggbbaa・rgb() などの関数表記・英字だけの色名、を通す緩い判定にする。
105
+ */
106
+ export function isColor(value: string): boolean {
107
+ if (value === '')
108
+ return false;
109
+
110
+ const css = (globalThis as { CSS?: { supports?: (p: string, v: string) => boolean } }).CSS;
111
+
112
+ if (css && typeof css.supports === 'function')
113
+ return css.supports('color', value);
114
+
115
+ return /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value)
116
+ || /^(rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\(.+\)$/i.test(value)
117
+ || /^[a-z]+$/i.test(value);
118
+ }
119
+
120
+ function toColor(value: string, label: string): string {
121
+ if (!isColor(value))
122
+ throw new ModelError(`${label}は、色 (#ff0000 や red など) で入力してください。`);
123
+
124
+ return value;
125
+ }
126
+
127
+ // 0 より大きい数(枠線・線の太さ。d3.deployment は 0 を「書いていない」と見て既定に倒すので、0 は入れられない)
128
+ function toWidth(value: string, label: string): number {
129
+ const n = toNumber(value);
130
+
131
+ if (n === null || n <= 0)
132
+ throw new ModelError(`${label}は、0 より大きい数で入力してください。`);
133
+
134
+ return n;
135
+ }
136
+
137
+ // 0 以上の整数(幅・高さ。0 は「子に合わせる」)
138
+ function toLength(value: string, label: string): number {
139
+ const n = toNumber(value);
140
+
141
+ if (n === null || n < 0)
142
+ throw new ModelError(`${label}は 0 以上の数で入力してください。(0 で子に合わせる)`);
143
+
144
+ return Math.round(n);
145
+ }
146
+
147
+ // 角度(0 以上 360 未満の整数に正規化)
148
+ function toDegree(value: string, label: string): number {
149
+ const n = toNumber(value);
150
+
151
+ if (n === null)
152
+ throw new ModelError(`${label}は、角度 (数) で入力してください。`);
153
+
154
+ return ((Math.round(n) % 360) + 360) % 360;
155
+ }
156
+
157
+ function toOption(value: string, options: readonly string[], label: string): string {
158
+ if (!options.includes(value))
159
+ throw new ModelError(`${label}は、${options.join(' / ')} のどれかで入力してください。`);
160
+
161
+ return value;
162
+ }
163
+
164
+ // true / false
165
+ function toBoolean(value: string, label: string): boolean {
166
+ if (value !== 'true' && value !== 'false')
167
+ throw new ModelError(`${label}は、true / false のどちらかで入力してください。`);
168
+
169
+ return value === 'true';
170
+ }
171
+
172
+ // 空にできる文字列。空なら null(JSON Merge Patch で項目を除く)
173
+ function toOptionalText(value: string): string | null {
174
+ return value === '' ? null : value;
175
+ }
176
+
177
+ // patch の入れ子に値を置く(patch.border.color = v のように)
178
+ function put(patch: MergePatch, path: string, value: unknown): void {
179
+ const keys = path.split('.');
180
+ let cur = patch;
181
+
182
+ for (const key of keys.slice(0, -1)) {
183
+ if (!cur[key] || typeof cur[key] !== 'object')
184
+ cur[key] = {};
185
+
186
+ cur = cur[key] as MergePatch;
187
+ }
188
+
189
+ cur[keys[keys.length - 1]] = value;
190
+ }
191
+
192
+ /**
193
+ * インスペクタの入力のうち、図に描いている値(withDefaults で埋めた値)から変わった項目だけを返す。
194
+ * 前後の空白は除く。読み取り専用の項目と、values に無い項目は含めない。
195
+ */
196
+ function changedValues(record: DeploymentRecord, values: DeploymentValues): Map<string, string> {
197
+ const effective = withDefaults(record);
198
+ const out = new Map<string, string>();
199
+
200
+ for (const field of fieldsOf(record)) {
201
+ if (field.type === 'readonly')
202
+ continue;
203
+
204
+ const value = values[field.path];
205
+
206
+ if (value === undefined)
207
+ continue;
208
+
209
+ const current = getByPath(effective, field.path);
210
+
211
+ if (value.trim() !== String(current ?? ''))
212
+ out.set(field.path, value.trim());
213
+ }
214
+
215
+ return out;
216
+ }
217
+
218
+ // ノードの入力 → patch(label.text / type / description / link.url / size / background / border)
219
+ function nodePatch(node: DeploymentNodeRecord, values: DeploymentValues): MergePatch {
220
+ const patch: MergePatch = {};
221
+
222
+ for (const [path, value] of changedValues(node, values)) {
223
+ switch (path) {
224
+ case 'label.text':
225
+ // 名称は空にできない(d3.deployment は名称の無いノードに '????????' を描く)
226
+ if (value === '')
227
+ throw new ModelError('名称を入力してください。');
228
+
229
+ put(patch, path, value);
230
+ break;
231
+ case 'type':
232
+ put(patch, path, toOption(value, NODE_TYPES, '種類'));
233
+ break;
234
+ case 'description':
235
+ put(patch, path, toOptionalText(value));
236
+ break;
237
+ case 'link.url':
238
+ // URL を空にするとリンクごと除く(d3.deployment は url の無い link を描かない)
239
+ if (value === '')
240
+ patch.link = null;
241
+ else
242
+ put(patch, path, value);
243
+ break;
244
+ case 'size.w':
245
+ put(patch, path, toLength(value, '幅'));
246
+ break;
247
+ case 'size.h':
248
+ put(patch, path, toLength(value, '高さ'));
249
+ break;
250
+ case 'background.color':
251
+ put(patch, path, toColor(value, '背景色'));
252
+ break;
253
+ case 'border.color':
254
+ put(patch, path, toColor(value, '枠線の色'));
255
+ break;
256
+ case 'border.width':
257
+ put(patch, path, toWidth(value, '枠線の太さ'));
258
+ break;
259
+ }
260
+ }
261
+
262
+ return patch;
263
+ }
264
+
265
+ const END_LABELS: Record<string, string> = { from: '接続元', to: '接続先' };
266
+
267
+ // 線の入力 → patch(from / to の角度・stroke・矢印)
268
+ function edgePatch(edge: DeploymentEdgeRecord, values: DeploymentValues): MergePatch {
269
+ const patch: MergePatch = {};
270
+
271
+ for (const [path, value] of changedValues(edge, values)) {
272
+ switch (path) {
273
+ case 'from.position':
274
+ case 'to.position':
275
+ put(patch, path, toDegree(value, `${END_LABELS[path.split('.')[0]]}の位置`));
276
+ break;
277
+ case 'stroke.color':
278
+ put(patch, path, toColor(value, '線の色'));
279
+ break;
280
+ case 'stroke.width':
281
+ put(patch, path, toWidth(value, '線の太さ'));
282
+ break;
283
+ case 'stroke.marker.start':
284
+ put(patch, path, toBoolean(value, '始点の矢印'));
285
+ break;
286
+ case 'stroke.marker.end':
287
+ put(patch, path, toBoolean(value, '終点の矢印'));
288
+ break;
289
+ }
290
+ }
291
+
292
+ // 矢印を片方だけ書くと d3.deployment の Edge.normalize は marker を丸ごと入力で置き換える(無い側は undefined =
293
+ // start は偽、end は truthy 判定で偽)ので、片方を変えたときはもう片方も描いている値で埋める
294
+ const marker = (patch.stroke as MergePatch | undefined)?.marker as MergePatch | undefined;
295
+
296
+ if (marker) {
297
+ const effective = withDefaults(edge).stroke!.marker!;
298
+
299
+ if (marker.start === undefined) marker.start = effective.start;
300
+ if (marker.end === undefined) marker.end = effective.end;
301
+ }
302
+
303
+ return patch;
304
+ }
305
+
306
+ /**
307
+ * インスペクタの「適用」の値を、保存する patch に変換する。
308
+ * 図に描いている値から変えた項目だけを patch に入れる(変えていない既定の値まで書き込まないため)。
309
+ * 変更が無ければ patch は {}。
310
+ */
311
+ export function applyPatch(record: DeploymentRecord, values: DeploymentValues): DeploymentChange {
312
+ return {
313
+ collection: collectionOf(record),
314
+ id: record.id,
315
+ patch: isEdge(record) ? edgePatch(record, values) : nodePatch(record as DeploymentNodeRecord, values),
316
+ };
317
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,100 @@
1
+ // d3.deployment の入力データの JSON Schema(2020-12)。
2
+ //
3
+ // iHub の server/schemas/deployment/{nodes,edges}.schema.json を移したもの。
4
+ // 検査するのは、図の構造にかかわる項目(id・type・size・position・children・link、線の from / to)だけで、
5
+ // 見た目の項目(label・border・background・padding・description)は d3.deployment の入力の形のまま書けるよう
6
+ // additionalProperties を閉じていない(iHub 版は閉じていた)。
7
+ //
8
+ // x-id-start はスキーマの検証には使わない注釈(コレクションが空のときに最初に振る id)。
9
+ // Ajv で使うときは addKeyword({ keyword: 'x-id-start', schemaType: 'number' }) を足す。
10
+
11
+ import { NODE_TYPES } from './defaults.js';
12
+
13
+ const ID = { type: ['integer', 'string'] } as const;
14
+
15
+ const POINT = {
16
+ type: 'object',
17
+ properties: { x: { type: 'number' }, y: { type: 'number' } },
18
+ required: ['x', 'y'],
19
+ } as const;
20
+
21
+ export const NODES_SCHEMA = {
22
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
23
+ title: 'システム構成図のノード',
24
+ description: 'd3.deployment に渡すノード。children に子のノードを入れ子で持つ。id は子のノードも含めて一意にする。position は親の中での位置で、ドラッグで動かすと書き換わる。link を付けると、ノードの左下にリンクを出す。見た目の項目 (label・padding・border・background・description) は d3.deployment の入力の形のまま書ける。',
25
+ 'x-id-start': 1,
26
+ type: 'array',
27
+ items: { $ref: '#/$defs/node' },
28
+ $defs: {
29
+ node: {
30
+ type: 'object',
31
+ properties: {
32
+ id: ID,
33
+ type: { enum: [...NODE_TYPES] },
34
+ label: {
35
+ type: 'object',
36
+ properties: { text: { type: ['string', 'null'] } },
37
+ },
38
+ description: { type: ['string', 'null'] },
39
+ size: {
40
+ type: 'object',
41
+ properties: { w: { type: 'number', minimum: 0 }, h: { type: 'number', minimum: 0 } },
42
+ required: ['w', 'h'],
43
+ },
44
+ position: POINT,
45
+ link: {
46
+ type: ['object', 'null'],
47
+ properties: { url: { type: ['string', 'null'] } },
48
+ },
49
+ children: { type: 'array', items: { $ref: '#/$defs/node' } },
50
+ },
51
+ required: ['id'],
52
+ additionalProperties: true,
53
+ },
54
+ },
55
+ } as const;
56
+
57
+ export const EDGES_SCHEMA = {
58
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
59
+ title: 'システム構成図の線',
60
+ description: 'ノードの間の線。from.id / to.id はノード (子を含む) の id。position は接続位置の角度 (0=真下・時計回り。省略すると 90=左)。stroke.marker は両端の矢印の有無 (既定: 終点だけ)。',
61
+ 'x-id-start': 100,
62
+ type: 'array',
63
+ items: {
64
+ type: 'object',
65
+ properties: {
66
+ id: ID,
67
+ from: { $ref: '#/$defs/end' },
68
+ to: { $ref: '#/$defs/end' },
69
+ stroke: {
70
+ type: 'object',
71
+ properties: {
72
+ color: { type: 'string' },
73
+ width: { type: 'number' },
74
+ marker: {
75
+ type: 'object',
76
+ properties: { start: { type: 'boolean' }, end: { type: 'boolean' } },
77
+ },
78
+ },
79
+ },
80
+ },
81
+ required: ['id', 'from', 'to'],
82
+ additionalProperties: true,
83
+ },
84
+ $defs: {
85
+ end: {
86
+ type: 'object',
87
+ properties: {
88
+ id: ID,
89
+ position: { type: 'number' },
90
+ },
91
+ required: ['id'],
92
+ },
93
+ },
94
+ } as const;
95
+
96
+ /** コレクション名 → スキーマ */
97
+ export const SCHEMAS = {
98
+ nodes: NODES_SCHEMA,
99
+ edges: EDGES_SCHEMA,
100
+ } as const;
package/src/types.ts ADDED
@@ -0,0 +1,102 @@
1
+ // model.deployment の公開型。
2
+ //
3
+ // ここで言う「レコード」は、d3.deployment の data() に渡す入力そのもの({ nodes, edges })の 1 要素。
4
+ // iHub では data/deployment/{nodes,edges}.json の 1 要素。d3.deployment が正規化したあとの datum(_id / _core / _position を持つもの)ではない。
5
+ // ノードと線の図なので model.unchi / model.sitemap と形が近い。ラベルは label.text(unchi と同じ。sitemap は label.contents)。
6
+
7
+ import type { Background, Border, Font, Position, Size, Stroke } from '@yanqirenshi/types';
8
+
9
+ /** レコードの id(nodes / edges とも。ノードは子も含めて一意) */
10
+ export type DeploymentId = number | string;
11
+
12
+ /** ノードの種類。NODE(通常のノード)か COMPONENT */
13
+ export type DeploymentNodeType = 'NODE' | 'COMPONENT';
14
+
15
+ /** ノードのラベル */
16
+ export interface DeploymentNodeLabel {
17
+ text?: string | null;
18
+ position?: Position;
19
+ font?: Font;
20
+ }
21
+
22
+ /** ノードのレコード(入れ子可)。ライブラリが解釈しない項目も持てる(index signature) */
23
+ export interface DeploymentNodeRecord {
24
+ id: DeploymentId;
25
+ type?: DeploymentNodeType | string;
26
+ label?: DeploymentNodeLabel | null;
27
+ /** ホバーのツールチップ(SVG title) */
28
+ description?: string | null;
29
+ size?: Size;
30
+ position?: Position;
31
+ padding?: number;
32
+ border?: Border;
33
+ background?: Background;
34
+ /** ノードの左下に出すリンク */
35
+ link?: { url?: string | null; [key: string]: unknown } | null;
36
+ children?: DeploymentNodeRecord[];
37
+ [key: string]: unknown;
38
+ }
39
+
40
+ /** 線の端点。position はノードの中心から見た角度(0=真下・時計回り。90=左 / 180=真上 / 270=右) */
41
+ export interface DeploymentEdgeEnd {
42
+ id: DeploymentId;
43
+ position?: number;
44
+ }
45
+
46
+ /** 線の見た目。marker は両端の矢印の有無 */
47
+ export interface DeploymentEdgeStroke extends Stroke {
48
+ marker?: { start?: boolean; end?: boolean };
49
+ }
50
+
51
+ /** 線のレコード */
52
+ export interface DeploymentEdgeRecord {
53
+ id: DeploymentId;
54
+ from: DeploymentEdgeEnd;
55
+ to: DeploymentEdgeEnd;
56
+ stroke?: DeploymentEdgeStroke;
57
+ [key: string]: unknown;
58
+ }
59
+
60
+ export type DeploymentRecord = DeploymentNodeRecord | DeploymentEdgeRecord;
61
+
62
+ /** 図全体(d3.deployment の data() に渡すもの) */
63
+ export interface DeploymentGraph {
64
+ nodes?: DeploymentNodeRecord[];
65
+ edges?: DeploymentEdgeRecord[];
66
+ }
67
+
68
+ /** レコードを置くコレクション */
69
+ export type DeploymentCollection = 'nodes' | 'edges';
70
+
71
+ /** JSON Merge Patch(RFC 7386)。null は「その項目を除く」 */
72
+ export type MergePatch = { [key: string]: unknown };
73
+
74
+ /** 保存する変更。利用側は図の種類を知らずにこれを保存すればよい */
75
+ export interface DeploymentChange {
76
+ collection: DeploymentCollection;
77
+ id: DeploymentId;
78
+ patch: MergePatch;
79
+ }
80
+
81
+ /** ドラッグの移動量(世界座標。d3.deployment の callbacks.node.dragend が渡す move と同じ) */
82
+ export interface DeploymentMove {
83
+ dx: number;
84
+ dy: number;
85
+ }
86
+
87
+ /**
88
+ * 編集できる項目。colonoscope の ColonoscopeField(path / label / type)と同じ形で、
89
+ * 取りうる値の一覧 options だけ足している(型は構造的に互換。colonoscope には依存しない)。
90
+ */
91
+ export type DeploymentFieldType = 'text' | 'textarea' | 'number' | 'readonly';
92
+
93
+ export interface DeploymentField {
94
+ /** レコードの項目のパス(ドット区切り。例: 'border.color')。写し先ではなく実際の項目を指す */
95
+ path: string;
96
+ label: string;
97
+ type: DeploymentFieldType;
98
+ options?: readonly string[];
99
+ }
100
+
101
+ /** インスペクタの「適用」が渡す値(path → 入力した文字列) */
102
+ export type DeploymentValues = Record<string, string>;