@graphty/cytoscape-extensions 0.0.9 → 0.0.11
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/package.json +7 -7
- package/src/elements.ts +60 -5
- package/src/io.ts +42 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graphty/cytoscape-extensions",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.11",
|
|
4
4
|
"description": "Graphty layouts, algorithms, graph generators, sample datasets and file import/export as Cytoscape.js extensions",
|
|
5
5
|
"author": "Adam Powers <apowers@ato.ms>",
|
|
6
6
|
"type": "module",
|
|
@@ -71,9 +71,9 @@
|
|
|
71
71
|
},
|
|
72
72
|
"homepage": "https://github.com/graphty-org/graphty-monorepo/tree/master/cytoscape-extensions#readme",
|
|
73
73
|
"dependencies": {
|
|
74
|
-
"@graphty/graph-io": "^0.3.
|
|
75
|
-
"@graphty/graph-
|
|
76
|
-
"@graphty/
|
|
74
|
+
"@graphty/graph-io": "^0.3.36",
|
|
75
|
+
"@graphty/webgpu-graph-algorithms": "^0.6.46",
|
|
76
|
+
"@graphty/graph-samples": "^0.1.28"
|
|
77
77
|
},
|
|
78
78
|
"peerDependencies": {
|
|
79
79
|
"@graphty/algorithms": "^3.0.0",
|
|
@@ -102,9 +102,9 @@
|
|
|
102
102
|
"typescript": "^5.9.3",
|
|
103
103
|
"vite": "^7.0.5",
|
|
104
104
|
"vitest": "4.1.11",
|
|
105
|
-
"@graphty/
|
|
106
|
-
"@graphty/
|
|
107
|
-
"@graphty/
|
|
105
|
+
"@graphty/graph-format": "^1.3.13",
|
|
106
|
+
"@graphty/layout": "^2.4.1",
|
|
107
|
+
"@graphty/algorithms": "^3.4.3"
|
|
108
108
|
},
|
|
109
109
|
"scripts": {
|
|
110
110
|
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && vite build --config vite.bundle.config.ts --mode cdn --logLevel warn && vite build --config vite.bundle.config.ts --logLevel warn",
|
package/src/elements.ts
CHANGED
|
@@ -23,6 +23,29 @@ function plain(v: unknown): unknown {
|
|
|
23
23
|
return ArrayBuffer.isView(v) ? Array.from(v as unknown as ArrayLike<number>) : v;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* RGB(A) channels in 0..1 as a CSS color: `#rrggbb` when opaque, `rgba(r, g, b, a)` otherwise.
|
|
28
|
+
* @param c - the channels
|
|
29
|
+
* @returns the color
|
|
30
|
+
*/
|
|
31
|
+
function cssColor(c: ArrayLike<number>): string {
|
|
32
|
+
const ch = (i: number): number => Math.max(0, Math.min(255, Math.round(c[i] * 255)));
|
|
33
|
+
const a = c.length > 3 ? Math.round(c[3] * 1000) / 1000 : 1;
|
|
34
|
+
if (a === 1) {
|
|
35
|
+
return `#${[0, 1, 2].map((i) => ch(i).toString(16).padStart(2, "0")).join("")}`;
|
|
36
|
+
}
|
|
37
|
+
return `rgba(${ch(0)}, ${ch(1)}, ${ch(2)}, ${a})`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether a column is a color: the role "color" with 3 or 4 numeric components (GEXF's viz:color).
|
|
42
|
+
* @param c - the column
|
|
43
|
+
* @returns true for a color column
|
|
44
|
+
*/
|
|
45
|
+
function isColor(c: Column): boolean {
|
|
46
|
+
return c.meta.role === "color" && (c.meta.components === 3 || c.meta.components === 4) && c.dtype !== "json";
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
/**
|
|
27
50
|
* A y coordinate between Cytoscape (y grows downward) and graph-io (y grows upward, so a Cytoscape-family file's
|
|
28
51
|
* screen y is negated on import and negated back on export). Never yields -0.
|
|
@@ -33,6 +56,9 @@ export function flipY(y: number): number {
|
|
|
33
56
|
return y === 0 ? 0 : -y;
|
|
34
57
|
}
|
|
35
58
|
|
|
59
|
+
/** Reads a CSS color as RGBA channels in 0..1, or undefined when it cannot. */
|
|
60
|
+
type ColorParser = (text: string) => [number, number, number, number] | undefined;
|
|
61
|
+
|
|
36
62
|
/** A column that arrives under another data field name, because Cytoscape reserves its own. */
|
|
37
63
|
interface RenamedColumn {
|
|
38
64
|
readonly domain: "node" | "edge";
|
|
@@ -44,6 +70,7 @@ interface RenamedColumn {
|
|
|
44
70
|
* Cytoscape element definitions for a snapshot. Node ids are the snapshot's ids as strings. Every node and edge
|
|
45
71
|
* column becomes a data field of the same name, except that one named `id`, `source`, `target` or `parent` (which
|
|
46
72
|
* Cytoscape reserves) becomes `<name>#<the attribute's id in the file>`, or `<name>#2` when it has none, and:
|
|
73
|
+
* - a column with the role "color" (GEXF's viz:color) becomes a CSS color string, `#rrggbb` or `rgba(r, g, b, a)`;
|
|
47
74
|
* - a node column with the role "position" becomes the node's position (x and y; a z is dropped);
|
|
48
75
|
* - a node column with the role "parent" (a compound parent, as Cytoscape JSON, GraphML nested graphs and DOT
|
|
49
76
|
* clusters carry it) becomes `data.parent`;
|
|
@@ -126,7 +153,8 @@ function rowData(cols: readonly Column[], row: number, fieldOf: (c: Column) => s
|
|
|
126
153
|
const data: Record<string, unknown> = {};
|
|
127
154
|
for (const c of cols) {
|
|
128
155
|
if (c.isSet(row)) {
|
|
129
|
-
|
|
156
|
+
const v = c.value(row);
|
|
157
|
+
data[fieldOf(c)] = isColor(c) ? cssColor(v as ArrayLike<number>) : plain(v);
|
|
130
158
|
}
|
|
131
159
|
}
|
|
132
160
|
return data;
|
|
@@ -259,12 +287,30 @@ function column(values: unknown[], label: boolean): ColumnInput | undefined {
|
|
|
259
287
|
return { data: values.map((v) => v ?? undefined), decl: { dtype: "json" } };
|
|
260
288
|
}
|
|
261
289
|
|
|
290
|
+
/**
|
|
291
|
+
* A string column of CSS colors as a color column (role "color", RGBA in 0..1), which GEXF writes as viz:color.
|
|
292
|
+
* @param c - the column
|
|
293
|
+
* @param parse - reads one color
|
|
294
|
+
* @returns the color column, or undefined when it is not a string column or a value is not a color `parse` reads
|
|
295
|
+
*/
|
|
296
|
+
function colorColumn(c: ColumnInput, parse: ColorParser): ColumnInput | undefined {
|
|
297
|
+
if (c.decl.dtype !== "string") {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
const rows = (c.data as (string | null)[]).map((v) => (v === null ? null : parse(v)));
|
|
301
|
+
if (rows.includes(undefined)) {
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
return { data: rows, decl: { dtype: "f32", components: 4, role: "color" } };
|
|
305
|
+
}
|
|
306
|
+
|
|
262
307
|
/**
|
|
263
308
|
* The columns of a set of elements' data, one per field, skipping the reserved ones.
|
|
264
309
|
* @param records - each element's data
|
|
310
|
+
* @param colors - when given, a `color` field whose every value it reads is written as the role "color"
|
|
265
311
|
* @returns the columns by name
|
|
266
312
|
*/
|
|
267
|
-
function columns(records: readonly Record<string, unknown>[]): Record<string, ColumnInput> {
|
|
313
|
+
function columns(records: readonly Record<string, unknown>[], colors?: ColorParser): Record<string, ColumnInput> {
|
|
268
314
|
const names = new Set<string>();
|
|
269
315
|
for (const r of records) {
|
|
270
316
|
for (const k of Object.keys(r)) {
|
|
@@ -280,7 +326,7 @@ function columns(records: readonly Record<string, unknown>[]): Record<string, Co
|
|
|
280
326
|
name === "label",
|
|
281
327
|
);
|
|
282
328
|
if (c !== undefined) {
|
|
283
|
-
out[name] = c;
|
|
329
|
+
out[name] = colors !== undefined && name === "color" ? (colorColumn(c, colors) ?? c) : c;
|
|
284
330
|
}
|
|
285
331
|
}
|
|
286
332
|
return out;
|
|
@@ -296,6 +342,8 @@ function columns(records: readonly Record<string, unknown>[]): Record<string, Co
|
|
|
296
342
|
* @param eles - the collection
|
|
297
343
|
* @param options - the direction and the id type
|
|
298
344
|
* @param options.directed - write a directed graph (default false)
|
|
345
|
+
* @param options.colors - reads a CSS color as RGBA in 0..1; when given, a `color` data field whose every value it
|
|
346
|
+
* reads is written as the role "color", for a format with a color slot of its own (GEXF's viz:color)
|
|
299
347
|
* @param options.integerIds - when every node id is a non-negative integer written plainly ("0", "17"), give the
|
|
300
348
|
* snapshot number ids, so a format that holds only integer ids (GML, CX2) writes them as they are (default false)
|
|
301
349
|
* @returns the snapshot
|
|
@@ -305,6 +353,7 @@ export function elementsToSnapshot(
|
|
|
305
353
|
options: {
|
|
306
354
|
readonly directed?: boolean | undefined;
|
|
307
355
|
readonly integerIds?: boolean | undefined;
|
|
356
|
+
readonly colors?: ColorParser | undefined;
|
|
308
357
|
} = {},
|
|
309
358
|
): GraphSnapshot {
|
|
310
359
|
const nodes = eles.nodes();
|
|
@@ -329,7 +378,10 @@ export function elementsToSnapshot(
|
|
|
329
378
|
xy[2 * i + 1] = flipY(p.y);
|
|
330
379
|
});
|
|
331
380
|
const parents = nodes.map((n) => index.get(n.parent().first().id()) ?? null);
|
|
332
|
-
const edgeColumns = columns(
|
|
381
|
+
const edgeColumns = columns(
|
|
382
|
+
edges.map((e) => e.data() as Record<string, unknown>),
|
|
383
|
+
options.colors,
|
|
384
|
+
);
|
|
333
385
|
let weights: Float64Array<ArrayBuffer> | undefined;
|
|
334
386
|
const w = edgeColumns.weight;
|
|
335
387
|
if (w?.decl.dtype === "f64" && (w.data as unknown[]).every((v) => v !== null)) {
|
|
@@ -343,7 +395,10 @@ export function elementsToSnapshot(
|
|
|
343
395
|
dst,
|
|
344
396
|
weights,
|
|
345
397
|
nodeColumns: {
|
|
346
|
-
...columns(
|
|
398
|
+
...columns(
|
|
399
|
+
nodes.map((n) => n.data() as Record<string, unknown>),
|
|
400
|
+
options.colors,
|
|
401
|
+
),
|
|
347
402
|
position: { data: xy, decl: { dtype: "f64", components: 2, role: "position" } },
|
|
348
403
|
...(parents.some((p) => p !== null)
|
|
349
404
|
? { parent: { data: parents, decl: { dtype: "u32", role: "parent", refersTo: "node" } } }
|
package/src/io.ts
CHANGED
|
@@ -163,6 +163,46 @@ function lostForCytoscape(n: LossNote): boolean {
|
|
|
163
163
|
return !(n.code === "W_ROLE_DROPPED" && n.column === "label");
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* A CSS color as RGBA channels in 0..1: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()`, `rgba()`, `hsl()` and
|
|
168
|
+
* `hsla()` with comma-separated arguments. A named color ("red") is not read.
|
|
169
|
+
* @param text - the color
|
|
170
|
+
* @returns the channels, or undefined when the text is not one of those forms
|
|
171
|
+
*/
|
|
172
|
+
function parseCssColor(text: string): [number, number, number, number] | undefined {
|
|
173
|
+
const s = text.trim().toLowerCase();
|
|
174
|
+
const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(s)?.[1];
|
|
175
|
+
if (hex !== undefined) {
|
|
176
|
+
const pairs = hex.length <= 4 ? Array.from(hex, (h) => h + h) : (hex.match(/../g) ?? []);
|
|
177
|
+
const [r, g, b, a = 255] = pairs.map((h) => Number.parseInt(h, 16) / 255);
|
|
178
|
+
return [r, g, b, a === 255 ? 1 : a];
|
|
179
|
+
}
|
|
180
|
+
const fn = /^(rgba?|hsla?)\(([^)]*)\)$/.exec(s);
|
|
181
|
+
const args = fn?.[2].split(",").map((a) => a.trim());
|
|
182
|
+
if (fn === undefined || fn === null || args === undefined || (args.length !== 3 && args.length !== 4)) {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
// each argument: a number, or a percentage of `full`
|
|
186
|
+
const num = (a: string, full: number): number =>
|
|
187
|
+
a.endsWith("%") ? (Number(a.slice(0, -1)) / 100) * full : Number(a);
|
|
188
|
+
const alpha = args.length === 4 ? num(args[3], 1) : 1;
|
|
189
|
+
let rgb: number[];
|
|
190
|
+
if (fn[1].startsWith("rgb")) {
|
|
191
|
+
rgb = args.slice(0, 3).map((a) => num(a, 255) / 255);
|
|
192
|
+
} else {
|
|
193
|
+
const h = Number(args[0].replace(/deg$/, ""));
|
|
194
|
+
const sat = num(args[1], 1);
|
|
195
|
+
const l = num(args[2], 1);
|
|
196
|
+
const k = (n: number): number => (n + h / 30) % 12;
|
|
197
|
+
const c = sat * Math.min(l, 1 - l);
|
|
198
|
+
rgb = [0, 8, 4].map((n) => l - c * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)));
|
|
199
|
+
}
|
|
200
|
+
const out = [...rgb, alpha];
|
|
201
|
+
return out.every((v) => Number.isFinite(v) && v >= 0 && v <= 1)
|
|
202
|
+
? (out as [number, number, number, number])
|
|
203
|
+
: undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
166
206
|
/** The formats that hold only integer node ids: a graph whose ids are all integers is written with them as they are. */
|
|
167
207
|
const INTEGER_ID_FORMATS: ReadonlySet<ExportFormat> = new Set(["gml", "cx2"]);
|
|
168
208
|
|
|
@@ -189,6 +229,8 @@ export async function exportElements(
|
|
|
189
229
|
const snapshot = elementsToSnapshot(eles, {
|
|
190
230
|
directed,
|
|
191
231
|
integerIds: INTEGER_ID_FORMATS.has(format),
|
|
232
|
+
// GEXF owns the name "color" for viz:color: a plain "color" field would read back renamed
|
|
233
|
+
colors: format === "gexf" ? parseCssColor : undefined,
|
|
192
234
|
});
|
|
193
235
|
const graphOptions = {
|
|
194
236
|
sanitizeIds: "mangle" as const,
|