Conversation
|
Your root-cause analysis is correct. There's a shorter route to the same pixels, though. Wrap every icon, raster included, in a padded square SVG and let The prototype I sent produces pixel-identical output to your branch across 4:1, 1:4, 1:1, 10:1 and a raster, in Chrome, Firefox and Safari 27. No measurement anywhere. Note that This also lands the canvas on the same mechanism What it removes
Still neededDelete Restore the Comment the Fix the PR description's SVG bullet. It says SVGs "already carry a viewBox, so they letterbox themselves naturally," but the branch rewrites every SVG's intrinsic size. Your commit message has it right. Rebase too. The Pre-existingThe review surfaced several older issues in files you touched. #2182 is filed; I'm tracking the rest. None block this. |
aea4a27 to
6660385
Compare
|
Thanks — the prototype made this much easier to reason about, and four of the five action items were straightforward. All are applied and the branch is rebased onto AppliedDeleted Restored the Added the Rebased.
On variant E — it breaks remote raster iconsThis is the one place I'd push back, and I think the harness hides it. I ran the wrapper through Chrome the way cytoscape consumes it (
In the external case the rendered colors are The cause is the sandbox your own The prototype can't surface this because its raster is So for a remote raster, E trades "distorted but recognizable" for "a broken-image glyph on every node of that type." On "the same mechanism That's why I kept the measurement. Working the Happy to be wrong about this if it reproduces differently for you. The probe is self-contained (spins its own server, generates its own PNG, no deps) and prints that table — I can drop it in a gist or add it to the prototype if useful. Worth re-running in Firefox/Safari; I only had Chrome headless, though the restriction is standard SVG-as-image sandboxing rather than a Chrome quirk. Two smaller notes on the framing: the 448 lines include ~196 lines of tests plus the 32-line file that's now gone (production delta is ~220), and the raster row in "pixel-identical across 4:1, 1:4, 1:1, 10:1 and a raster" is the inline-data-url case. PR descriptionYou're right that the SVG bullet was wrong — the branch does rewrite every SVG's intrinsic size. Corrected bullet:
I have applied that to the description, and corrected the "How to read" entry for |
|
Correcting my previous comment: my objection to variant E was wrong, and I'd rather retract it explicitly than leave it standing. Where I was wrongI claimed the padded-SVG wrapper would break remote raster icons. The browser behaviour I measured is real — inside a So the failure mode I described cannot occur, and my conclusion that the measurement is load-bearing does not follow from it. What I verified insteadI spiked variant E against the app's real cytoscape 3.34.3 with production node geometry from
You're right on the main point: E is pixel-equivalent everywhere it matters (the ±2px is antialiasing at 8× zoom), and it keeps the 60% inset. I also checked the thing I thought might bite — One entry to keep on the removal list
That case is reachable: it is what a plain PlanImplementing E now, keeping One implementation note: the wrapper has to be applied on the canvas path only, not inside Also worth noting the double percent-encoding is a non-issue: it inflates the style string 1.2× for rasters, 1.6× for SVG, 2.2× for lucide, with a largest absolute value of ~2.5KB. Unique icons stay in the dozens, so it doesn't approach the Schema View concern. Caveats on my numbers: Chrome headless only, the lucide row is a stand-in rather than real lucide output, and the bbox carries ~2px of antialiasing noise. |
6660385 to
4ca8fdd
Compare
|
Pushed as What went away: What stayed: I verified the branch's real output rather than a port of it — emitting the actual urls the hook produces, then rendering them on cytoscape 3.34.3 with the updated
The description is updated to describe the wrapper. The two commits from the earlier round (the NUL cache key, and never introducing the |
|
This is the approach I wanted, and it works. I verified it in the real app rather than a harness: dev server off your branch against a live
The inset is exact, 0.600 of the node box on the major axis in every case, centred to within half a device pixel. The 1:1 icon clears the ellipse outline by 18.9px, which is the part the first attempt got wrong. I reproduced the bug on the merge base with the same script for comparison: the 4:1 PNG came out 144x142 with the circle crushed to 28x114, aspect 0.246. One thing worth knowing that neither of us had established: a viewBox-carrying SVG was already rendering correctly before this branch. #2108 only ever bit raster icons and viewBox-less SVGs. My earlier comment overstated the SVG half. The nesting question is settled too. In Chrome, a wrapper with a nested One blocker
Fix: test There are also two validation gaps further upstream that let a bad value get stored in the first place. Those predate this branch and I'm tracking them separately, so don't worry about them here. Before merge
Share the inset geometry instead of redeclaring it. Two tests don't test what they claim. In There's no
Small
Nothing else: |
4ca8fdd to
b613704
Compare
|
Thank you for the geometry verification against a live container — that's a stronger check than anything I ran. All fixed, pushed as four commits on top of the rebase (now at FixedThe blocker. Confirmed:
Shared geometry. The two weak tests.
Docs. Tall-icon fixture added alongside the wide one. Two things I want to correct, since I'd rather flag it than let it stand
The "no Given both of those, I've left The
|
A custom icon whose width and height differ was stretched into a square on both the graph canvas and DOM surfaces (search results, legends), instead of being scaled down while preserving its aspect ratio. Affects raster images and SVGs alike. Root causes, all now fixed: - defaultNodeStyle forced backgroundWidth/backgroundHeight to the same 60% for every icon regardless of shape. useGraphStyles now computes per-icon percentages from the icon's real aspect ratio, falling back to 60%/60% only when dimensions are unknown. - Icon dimensions were never measured: raster icons resolved synchronously with no size info, and SVGs weren't inspected at all. iconRegistry now measures a raster's natural size via `Image`, and extracts an SVG's size from its width/height or viewBox. - iconImageUrl forced every SVG's own intrinsic width/height to a fixed 24x24 square before handing it to cytoscape. For a non-square icon this baked a mismatched-aspect letterbox into the rasterized image, which cytoscape's own aspect-aware background-width/height then stretched a second time — distorting worse than doing nothing. It now scales the intrinsic box to the icon's real aspect ratio instead. - An SVG with width/height but no viewBox has no coordinate system to scale from, so forcing a different display box just clips the content instead of scaling it. Added ensureSvgViewBox() to synthesize one when missing, applied wherever an SVG is sanitized (DOM render and canvas resolution) via a new shared SVG_ALLOWED_ATTR allowlist — DOMPurify's default SVG profile otherwise strips width/height/viewBox outright. - VertexIcon's plain `<img>` (non-SVG raster fallback) had no `object-fit`, so the browser's default `fill` stretched it; added `object-contain`. Added an `Image` test double to setupTests.ts, since jsdom never decodes images and would otherwise hang any test that resolves a raster icon's dimensions.
An Icon Source Id embeds the user-supplied icon url verbatim and the vertex color is an unvalidated string, so a printable separator lets two distinct (icon, color) pairs collide and swap icons between vertex types.
…tages Cytoscape cannot both preserve an image's aspect ratio and inset it, so the inset is baked into a square svg wrapper and the nested image's preserveAspectRatio does the fitting. That needs no measurement, so the raster Image() probe, the async raster path, the carried dimensions, and the per-type background width/height all go away. The wrapper is applied on the canvas path only. VertexSymbolIcon already insets to 60% in its own svg coordinates, so wrapping inside toIconImageUrl would apply the inset twice. Keeps ensureSvgViewBox: without a viewBox the nested image has no intrinsic ratio to fit and fills the padded box, coming out square, which is the bug itself.
insetIconImage's encodeURIComponent throws URIError on a url that is not well-formed UTF-16 (a lone surrogate). That runs during style computation, so an uncaught throw took down the whole app through the route-level error boundary, with no in-app way back to fix the stored value. Skip the icon and log a warning instead; the vertex renders with no background image. Pulled ICON_BOX, ICON_RATIO, and encodeSvg into a shared iconGeometry module so useBackgroundImageMap and VertexSymbol read one definition instead of two that could silently drift apart. Adopts 96 (VertexSymbol's already-reasoned value, 4x a canvas node) over the wrapper's previous arbitrary 100. Also adds a tall-icon (1:4, no viewBox) companion to the existing wide one, since only one axis was covered.
parseFloat let two malformed inputs through: width="1e400" parsed to Infinity, synthesizing a viewBox that blanks the icon, and width="100%" parsed to the unitless number 100, synthesizing a viewBox that crops the artwork instead of scaling it. Switched to a helper that rejects a trailing % and any non-finite result via Number.isFinite; a plain unit suffix like 400px still parses. Removed the try/catch: DOMParser with application/xml never throws on malformed input, it returns a document whose root is <html> wrapping a parsererror, or whichever mismatched tag the input happened to close. Either way root.localName is not "svg", so the existing guard already rejects it — confirmed with a second unparseable-input case and a comment explaining why it passes.
iconRegistry.test.ts's geometry test used toContain(attributeName), which a substring of an unrelated attribute already satisfies: preserveAspectRatio contains "d", and stroke-width contains "width". Asserts full attribute values with distinct numbers per attribute instead, so a wrong or missing value actually fails it. useGraphStyles.test.tsx's raster assertion only checked the data:image/svg+xml prefix, which passes even if the wrapper lost the url or wrapped the wrong one. Decodes the wrapper and checks the real icon url is nested inside. VertexIcon — the inline-DOM icon surface — had no test file, so its object-contain fix (the one this component actually needed for issue aws#2108) shipped unverified.
CONTEXT.md's Icon Surface entry said a raster renders as its plain url two sentences before the clause describing the canvas wrapping it — both are true, just for different surfaces, so scoped the sentence to say which. The ADR's raster row and its external-references sentence predated this PR and read as forbidding what the canvas now does (nesting one data: uri image inside another). Updated the row, qualified the sentence, and added a paragraph on why the wrapper doesn't cost the sandbox it sits inside. defaultNodeStyle's comment credited auto/auto with fixing aws#2108, but those restate cytoscape's own defaults; contain plus dropping the fixed percentages is what changed. Also named the ellipse as why contain alone (no wrapper) isn't enough, which is the one thing asked for last round.
b613704 to
c5b4f78
Compare
kmcginnes
left a comment
There was a problem hiding this comment.
The core fix holds up. Canvas and DOM both keep the aspect ratio, square icons keep the 60% inset, and VertexSymbol isn't double inset. A few things to address:
Should fix
- Raster icons from an
http(s)url go blank on the canvas.useBackgroundImageMap.tsnests the raster url in adata:SVG as<image href>, and an SVG loaded as an image won't fetch external resources. Uploaded icons are alreadydata:urls so they work, but aniconUrlpointing at a remote or relative path breaks. The tests only check the url string, so they wouldn't catch it. I'd skip the wrapper for non-data:rasters, or confirm none can reach this path. escapeXmlAttributedoesn't escape<. XML doesn't allow a bare<in attribute values, so a raster url containing one produces a malformed wrapper. SVG icons are fine because they're percent-encoded first.- CONTEXT.md still describes the measuring approach from the first commit. The Icon Registry entry says it holds the raster's "natural width/height" and that "every icon kind resolves asynchronously". Neither is true anymore. The stale docs commit missed this one.
Smaller
- A malformed url returns
nullwithout caching, sologger.warnfires again on every recompute. - An SVG with percentage
width/heightand noviewBoxstill gets noviewBox, so it still fills the box. encodeSvginiconGeometry.tsisn't geometry. I'd move it back toiconImageUrl.ts.- The
ICON_BOX * ICON_RATIOinset math is duplicated inVertexSymbol.tsxanduseBackgroundImageMap.ts. A shared helper iniconGeometrywould make it a real single source of truth. - The DOMPurify config plus
ensureSvgViewBoxsequence is duplicated inVertexIcon.tsxandiconRegistry.ts. - Several comments restate what the code does and could be trimmed to just the rationale:
defaultNodeStyle.ts, theinsetIconImageJSDoc,parseFiniteLength,iconGeometry.tsheader.
Skip the canvas wrapper for a non-data: raster url — an external reference nested inside a data: SVG sits in the SVG-as-image sandbox and fetches nothing, so it rendered blank. Escape < in the wrapped url too, alongside & and ", since a bare < is not legal in an XML attribute value. Neither case is reachable through any current write path (every stored icon url is lucide: or data:image/*;base64,), so both are defensive. Warn once per distinct malformed icon url instead of on every render: the within-render cache does not survive between calls, so without a persistent record the same stored value would log again on every recompute for as long as it stays stored. Share ICON_BOX, ICON_RATIO, and the inset math (a new insetBox helper) between the canvas wrapper and VertexSymbol's preview, so the two cannot silently desync — they had been computing the same size/offset formula independently. Moved encodeSvg back to iconImageUrl.ts, its original home; iconGeometry.ts holds geometry only. Barreled iconGeometry through core/icons/index.ts so it's reachable the same way every other module in the directory is. Deduped the DOMPurify-sanitize-then-ensureSvgViewBox sequence that VertexIcon.tsx and iconRegistry.ts each ran separately into one exported sanitizeSvg. iconRegistry's isParseableSvg check moves to after the combined call instead of between sanitizing and synthesizing a viewBox — ensureSvgViewBox no-ops on non-svg content, so this gives the same answer with one call instead of two. Corrected CONTEXT.md and the ADR, which still described the icon registry measuring a raster's natural dimensions and resolving every icon kind asynchronously — both true of the approach before the wrapper, neither true now. Documented, with a test, that a percentage-dimension svg with no viewBox has no recoverable aspect ratio and is left square rather than guessed at. Removed a CSS-class assertion from VertexIcon.test.tsx per this repo's testing conventions; jsdom does no layout, so it couldn't have caught the regression it was written for anyway.
Description
Non-square custom vertex icons (raster images and SVG) were stretched into a square instead of scaled down to fit, distorting them. This happened on both the graph canvas and DOM surfaces (search results, legend).
Root cause: cytoscape cannot both preserve an image's aspect ratio and inset it.
background-fit: containkeeps the ratio but fills the whole node box, and the nodes are ellipses, so a square-ish icon's corners spill outside the shape. Settingbackground-width/background-heightpercentages insets the icon but overrides the image's natural dimensions on both axes, sodrawInscribedImagesquashes it — the image is already distorted beforebackground-fitruns. The DOM rendered images withobject-fit: fill(the default), which behaves identically.How it's fixed
Padded SVG wrapper on the canvas. The inset is baked into a square SVG that wraps the icon, and the nested
<image preserveAspectRatio="xMidYMid meet">does the fitting. Cytoscape is then handed a square tocontainwithautoaxes, so it never forces a dimension. This needs no measurement and works the same for raster and SVG.The wrapper is applied on the canvas path only.
VertexSymbolIconalready insets to 60% in its own SVG coordinates, so wrapping insidetoIconImageUrlwould apply the inset twice and render at 36%. The wrapper is skipped for a raster url that isn't adata:uri: nesting an external reference inside adata:SVG puts it in the SVG-as-image sandbox, which fetches nothing external, so it would render blank rather than distorted. Every icon url reachable through the app today islucide:ordata:image/*;base64,, so this is defensive rather than a case anyone hits.viewBox synthesis. A
viewBoxis synthesized fromwidth/heightwhen an SVG has none. This is load-bearing, not a nicety: without aviewBoxthe nested image has no intrinsic ratio forpreserveAspectRatioto fit, so it fills the padded box and comes out square — the original bug. A plain<svg width height>export, which is what many icon exporters produce, hits exactly this. A percentage-dimension SVG with no viewBox (width="100%") has no recoverable aspect ratio at all and is left square — a documented, tested limitation rather than a guess.DOM surface.
VertexIcongainsobject-containso its<img>respects the ratio.Shared geometry and sanitization.
ICON_BOX,ICON_RATIO, and the inset math are centralized iniconGeometry.tsso the canvas wrapper and the style preview (VertexSymbol) can't silently drift apart. The DOMPurify-sanitize-then-viewBox-synthesize sequence, previously duplicated inVertexIcon.tsxand the icon registry, is now one sharedsanitizeSvg.Robustness. A stored icon url that isn't well-formed UTF-16 is skipped (with a one-time warning) instead of throwing during style computation — that throw previously took down the whole app via the route-level error boundary. The
(icon, color)render cache key uses a NUL separator instead of|, since a|in a color value could otherwise collide two different cache entries.How to read
src/modules/GraphViewer/useBackgroundImageMap.ts— the wrapper, the non-data:fallback, and why cytoscape can't do this alonesrc/components/Graph/styles/defaultNodeStyle.ts—containwithautoaxes, replacing the fixed 60% percentagessrc/core/icons/svgViewBox.ts— viewBox synthesis and the sharedsanitizeSvgsrc/core/icons/iconGeometry.ts— the geometry constants and inset math shared between the canvas and the previewsrc/core/icons/iconImageUrl.ts— bakes the color only; sizing is the consumer's jobsrc/components/VertexIcon.tsx—object-containon the DOM pathValidation
All unit tests pass, including regressions for: the no-viewBox case (the one input the wrapper alone doesn't handle), a non-
data:raster url, an unescaped<in the wrapped url, a malformed icon url, and the cache-key collision.Verified by rendering this branch's actual output on cytoscape 3.34.3 with the real node geometry, measuring the drawn icon as a pixel diff against an icon-free baseline (24px node at 8× zoom, so 192px is the full node):
Ratios match the sources (±antialiasing) and the 60% inset is preserved, including for the square control. Chrome headless only.
Also manually verified in the running app on the graph canvas, uploading a 4:1 SVG that declares
width/heightand noviewBox— the case the wrapper alone gets wrong — which renders at its natural aspect ratio.@kmcginnesindependently verified against a liveair_routescontainer, measuring geometry off the rendered canvas: 60% inset exact to within half a device pixel across 4:1/1:4/1:1 SVG, 4:1 PNG, and a viewBox-less 4:1 SVG.Before Fix

After Fix
