Skip to content

Images and CORS

An item can carry an image: texture: { url, fit? }. Once the item is at least textures.minDiameterPx across on screen (48 by default), the image is loaded and drawn inside its disc or square, cropped to the shape: cover (the default) fills it, contain fits the whole image. Until it loads, below that size, and if it fails, the plain shape shows. A cloud ignores texture.

The image is drawn into a WebGL canvas, and a browser lets a canvas use only pixels the page may read. Serve it from the same origin, as a data: URL, or from a host that sends Access-Control-Allow-Origin: the explorer requests it with crossOrigin = "anonymous". Anything else fails, the item keeps its shape, and onTextureError fires once for that URL. The same image in your own DOM (an <img> in a detail panel) has no such limit.

The demo’s faces are data: URLs. Its square uses https://github.com/SirKettle.png, which redirects to an avatar without a CORS header on the redirect: the log shows the canvas refusing it, and the same URL working in an <img>. Background images follow the same rule.

Running the code below: images.tsx
images.tsx
import { useState } from "react";
import { createSceneSource, DataExplorer } from "@data-xplorer/core";
// An image the canvas may draw: same origin, a data: URL, or a host that sends CORS headers.
const face = (hue: number) =>
"data:image/svg+xml," +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" fill="hsl(${hue} 70% 55%)"/><circle cx="22" cy="26" r="5"/><circle cx="42" cy="26" r="5"/><path d="M18 42q14 12 28 0" stroke="#000" stroke-width="4" fill="none"/></svg>`,
);
// An image it may not: GitHub's avatar redirect sends no CORS header. An <img> shows it; a canvas
// cannot, so that item keeps its plain disc and `onTextureError` reports the URL, once.
const NO_CORS = "https://github.com/SirKettle.png";
const people = ["Ada", "Ben", "Cleo", "Dev", "Eli"];
const source = createSceneSource<null>({
load: () => ({
id: "people",
heading: "People",
clusters: [],
nodes: [
...people.map((name, i) => ({
id: name,
label: name,
weight: 5,
href: `#${name}`,
drillsInto: null,
shape: "disc" as const,
texture: { url: face(i * 70) },
})),
{
id: "no-cors",
label: "No CORS",
weight: 5,
href: "#no-cors",
drillsInto: null,
shape: "square" as const,
texture: { url: NO_CORS },
},
],
edges: [],
}),
});
export default function Images() {
const [failed, setFailed] = useState<string[]>([]);
return (
<>
<DataExplorer
source={source}
focus={null}
textures={{ minDiameterPx: 24 }} // draw images from 24 px across (the default is 48)
onTextureError={({ url }) => setFailed((list) => [...list, url])}
/>
<pre className="demo-log">
{failed.length === 0 ? "No texture errors yet." : failed.map((url) => `onTextureError: ${url}`).join("\n")}
{"\nThe same URL in an <img>, which needs no CORS: "}<img src={NO_CORS} alt="" width={20} height={20} />
</pre>
</>
);
}

Each image is cropped once into a 256 px square (256 KB of GPU memory). At most textures.budget (128 by default, so 32 MB) are held or loading at once; the least recently drawn go first. Images drawn in the current frame are never evicted, so a view needing more than the budget draws the rest as plain shapes instead of reloading them every frame.