@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphty/cytoscape-extensions",
3
- "version": "0.0.9",
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.35",
75
- "@graphty/graph-samples": "^0.1.27",
76
- "@graphty/webgpu-graph-algorithms": "^0.6.45"
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/algorithms": "^3.4.2",
106
- "@graphty/graph-format": "^1.3.12",
107
- "@graphty/layout": "^2.4.0"
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
- data[fieldOf(c)] = plain(c.value(row));
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(edges.map((e) => e.data() as Record<string, unknown>));
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(nodes.map((n) => n.data() as Record<string, unknown>)),
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,