Skip to content

Commit 1f17171

Browse files
committed
Remove broken round-polygon shapes from the picker and coerce stored values (#1922)
Cytoscape's round-polygon renderer degenerates at 24px for six shapes (round-triangle, round-pentagon, round-hexagon, round-heptagon, round-octagon, round-tag), rendering them as blobs and causing edges to disappear. This removes them from the shape picker so they can't be newly selected, and coerces any previously-stored values to round-rectangle at both the storage-read boundary (ReadTransform) and the styling-import boundary (Zod transform). The shapes remain in SHAPE_STYLES so older files still parse without rejection.
1 parent cf8a6b4 commit 1f17171

8 files changed

Lines changed: 188 additions & 10 deletions

File tree

packages/graph-explorer/src/core/StateProvider/graphStyles.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import { DbState, renderHookWithState } from "@/utils/testing";
88
import {
99
appDefaultEdgeStyle,
1010
appDefaultVertexStyle,
11+
coerceBrokenShape,
1112
edgeStyleAtom,
1213
type EdgeStyleStorage,
14+
type ShapeStyle,
1315
useEdgeStyling,
1416
useVertexStyling,
1517
vertexStyleAtom,
@@ -460,3 +462,32 @@ describe("edgeStyleAtom", () => {
460462
);
461463
});
462464
});
465+
466+
describe("coerceBrokenShape", () => {
467+
const BROKEN: ShapeStyle[] = [
468+
"round-triangle",
469+
"round-pentagon",
470+
"round-hexagon",
471+
"round-heptagon",
472+
"round-octagon",
473+
"round-tag",
474+
];
475+
476+
it.each(BROKEN)("coerces %s to round-rectangle", shape => {
477+
expect(coerceBrokenShape(shape)).toBe("round-rectangle");
478+
});
479+
480+
const SAFE: ShapeStyle[] = [
481+
"ellipse",
482+
"rectangle",
483+
"round-rectangle",
484+
"round-diamond",
485+
"star",
486+
"diamond",
487+
"triangle",
488+
];
489+
490+
it.each(SAFE)("passes %s through unchanged", shape => {
491+
expect(coerceBrokenShape(shape)).toBe(shape);
492+
});
493+
});

packages/graph-explorer/src/core/StateProvider/graphStyles.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,31 @@ export const SHAPE_STYLES = [
4545
] as const;
4646
export type ShapeStyle = (typeof SHAPE_STYLES)[number];
4747

48+
/**
49+
* Shapes that cytoscape's round-polygon renderer draws incorrectly at small
50+
* node size (24px): their corner computation degenerates, rendering as a blob
51+
* and producing invalid edge endpoints that cause edges to disappear. These are
52+
* kept in {@link SHAPE_STYLES} so older files still parse, but are coerced to
53+
* `round-rectangle` at every read boundary.
54+
*/
55+
const BROKEN_ROUND_POLYGON_SHAPES: ReadonlySet<ShapeStyle> = new Set([
56+
"round-triangle",
57+
"round-pentagon",
58+
"round-hexagon",
59+
"round-heptagon",
60+
"round-octagon",
61+
"round-tag",
62+
]);
63+
64+
const BROKEN_SHAPE_REPLACEMENT: ShapeStyle = "round-rectangle";
65+
66+
/** Coerces a broken round-polygon shape to `round-rectangle`, passing all others through. */
67+
export function coerceBrokenShape(shape: ShapeStyle): ShapeStyle {
68+
return BROKEN_ROUND_POLYGON_SHAPES.has(shape)
69+
? BROKEN_SHAPE_REPLACEMENT
70+
: shape;
71+
}
72+
4873
export const LINE_STYLES = ["solid", "dashed", "dotted"] as const;
4974
export type LineStyle = (typeof LINE_STYLES)[number];
5075

packages/graph-explorer/src/core/StateProvider/storageAtoms.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
defaultSchemaViewLayout,
2020
transformSchemaViewLayout,
2121
} from "./schemaViewLayoutDefaults";
22+
import { transformVertexStyles } from "./vertexStylesTransform";
2223

2324
// Run migrations before the atoms preload so they read the migrated data.
2425
// Each migration owns its own failure reporting (surfacing through the
@@ -95,7 +96,7 @@ const [
9596
atomWithLocalForage(
9697
"user-vertex-styles",
9798
new Map<VertexType, VertexStyleStorage>(),
98-
{ reconcile: reconcileMapByKey },
99+
{ reconcile: reconcileMapByKey, transform: transformVertexStyles },
99100
),
100101
/** User-defined edge style overrides, keyed by type. */
101102
atomWithLocalForage(
@@ -111,7 +112,7 @@ const [
111112
atomWithLocalForage(
112113
"shared-vertex-styles",
113114
new Map<VertexType, VertexStyleStorage>(),
114-
{ reconcile: reconcileMapByKey },
115+
{ reconcile: reconcileMapByKey, transform: transformVertexStyles },
115116
),
116117
/** Shared edge styles, loaded from a styling file. */
117118
atomWithLocalForage(
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { createVertexType, type VertexType } from "@/core/entities";
2+
3+
import type { ShapeStyle, VertexStyleStorage } from "./graphStyles";
4+
5+
import { transformVertexStyles } from "./vertexStylesTransform";
6+
7+
function vertexMap(
8+
entries: Array<[string, Omit<VertexStyleStorage, "type">]>,
9+
): Map<VertexType, VertexStyleStorage> {
10+
return new Map(
11+
entries.map(([name, fields]) => {
12+
const type = createVertexType(name);
13+
return [type, { ...fields, type }];
14+
}),
15+
);
16+
}
17+
18+
describe("transformVertexStyles", () => {
19+
it("returns the same reference when no shapes need coercion", () => {
20+
const styles = vertexMap([
21+
["A", { shape: "ellipse" }],
22+
["B", { color: "#fff" }],
23+
]);
24+
25+
expect(transformVertexStyles(styles)).toBe(styles);
26+
});
27+
28+
it("coerces each broken round-polygon shape to round-rectangle", () => {
29+
const broken: ShapeStyle[] = [
30+
"round-triangle",
31+
"round-pentagon",
32+
"round-hexagon",
33+
"round-heptagon",
34+
"round-octagon",
35+
"round-tag",
36+
];
37+
38+
for (const shape of broken) {
39+
const styles = vertexMap([["X", { shape }]]);
40+
const result = transformVertexStyles(styles);
41+
expect(result.get(createVertexType("X"))!.shape).toBe("round-rectangle");
42+
}
43+
});
44+
45+
it("does not coerce round-rectangle or round-diamond", () => {
46+
const styles = vertexMap([
47+
["A", { shape: "round-rectangle" }],
48+
["B", { shape: "round-diamond" }],
49+
]);
50+
51+
expect(transformVertexStyles(styles)).toBe(styles);
52+
});
53+
54+
it("preserves other fields on a coerced entry", () => {
55+
const styles = vertexMap([
56+
["A", { shape: "round-tag", color: "#ff0000", borderWidth: 2 }],
57+
]);
58+
59+
const result = transformVertexStyles(styles);
60+
const entry = result.get(createVertexType("A"))!;
61+
expect(entry.shape).toBe("round-rectangle");
62+
expect(entry.color).toBe("#ff0000");
63+
expect(entry.borderWidth).toBe(2);
64+
});
65+
66+
it("handles an empty map", () => {
67+
const styles = vertexMap([]);
68+
expect(transformVertexStyles(styles)).toBe(styles);
69+
});
70+
71+
it("passes through entries without a shape field", () => {
72+
const styles = vertexMap([["A", { color: "#abc" }]]);
73+
expect(transformVertexStyles(styles)).toBe(styles);
74+
});
75+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { VertexType } from "../entities";
2+
3+
import { coerceBrokenShape, type VertexStyleStorage } from "./graphStyles";
4+
5+
/**
6+
* ReadTransform for vertex style maps: coerces broken round-polygon shapes to
7+
* `round-rectangle` at load time. Entries without a `shape` field are passed
8+
* through unchanged. Returns the same reference when no coercion was needed.
9+
*/
10+
export function transformVertexStyles(
11+
styles: Map<VertexType, VertexStyleStorage>,
12+
): Map<VertexType, VertexStyleStorage> {
13+
let changed = false;
14+
const result = new Map<VertexType, VertexStyleStorage>();
15+
16+
for (const [type, entry] of styles) {
17+
if (entry.shape !== undefined) {
18+
const coerced = coerceBrokenShape(entry.shape);
19+
if (coerced !== entry.shape) {
20+
result.set(type, { ...entry, shape: coerced });
21+
changed = true;
22+
continue;
23+
}
24+
}
25+
result.set(type, entry);
26+
}
27+
28+
return changed ? result : styles;
29+
}

packages/graph-explorer/src/core/styling/stylingParser.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,34 @@ describe("style enum validation", () => {
6464
"none",
6565
];
6666

67+
const BROKEN_SHAPES = new Set([
68+
"round-triangle",
69+
"round-pentagon",
70+
"round-hexagon",
71+
"round-heptagon",
72+
"round-octagon",
73+
"round-tag",
74+
]);
75+
6776
test.each(SHAPES)("accepts shape %s", shape => {
6877
const result = parseStylingPayload({
6978
vertices: { A: { shape } },
7079
edges: {},
7180
});
72-
expect(result.vertexStyles.get(createVertexType("A"))!.shape).toBe(shape);
81+
const expected = BROKEN_SHAPES.has(shape) ? "round-rectangle" : shape;
82+
expect(result.vertexStyles.get(createVertexType("A"))!.shape).toBe(
83+
expected,
84+
);
85+
});
86+
87+
test("coerces broken round-polygon shapes to round-rectangle on import", () => {
88+
const result = parseStylingPayload({
89+
vertices: { A: { shape: "round-tag" } },
90+
edges: {},
91+
});
92+
expect(result.vertexStyles.get(createVertexType("A"))!.shape).toBe(
93+
"round-rectangle",
94+
);
7395
});
7496

7597
test("rejects an unknown shape, listing the valid options", () => {

packages/graph-explorer/src/core/styling/stylingParser.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { createEdgeType, createVertexType } from "@/core/entities";
88
import { FileEnvelopeError } from "@/core/fileEnvelope";
99
import {
1010
ARROW_STYLES,
11+
coerceBrokenShape,
1112
type EdgeStyleStorage,
1213
LINE_STYLES,
1314
SHAPE_STYLES,
@@ -133,7 +134,7 @@ const vertexEntrySchema = z
133134
displayLabel: z.string().optional(),
134135
displayNameAttribute: z.string().optional(),
135136
longDisplayNameAttribute: z.string().optional(),
136-
shape: z.enum(SHAPE_STYLES).optional(),
137+
shape: z.enum(SHAPE_STYLES).transform(coerceBrokenShape).optional(),
137138
backgroundOpacity: z.number().optional(),
138139
borderWidth: z.number().optional(),
139140
borderColor: z.string().optional(),

packages/graph-explorer/src/modules/NodesStyling/nodeShape.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,7 @@ export const NODE_SHAPE = [
1111
{ label: "Rectangle", value: "rectangle" },
1212
{ label: "Rhomboid", value: "rhomboid" },
1313
{ label: "Round Diamond", value: "round-diamond" },
14-
{ label: "Round Heptagon", value: "round-heptagon" },
15-
{ label: "Round Hexagon", value: "round-hexagon" },
16-
{ label: "Round Octagon", value: "round-octagon" },
17-
{ label: "Round Pentagon", value: "round-pentagon" },
1814
{ label: "Round Rectangle", value: "roundrectangle" },
19-
{ label: "Round Tag", value: "round-tag" },
20-
{ label: "Round Triangle", value: "round-triangle" },
2115
{ label: "Star", value: "star" },
2216
{ label: "Tag", value: "tag" },
2317
{ label: "Triangle", value: "triangle" },

0 commit comments

Comments
 (0)