SVG flow visualization with directional arcs, arrows, and ranked labels. Works with any map library or standalone in any 2D coordinate space.
npm install flowringimport { flowring } from "flowring";
const ring = flowring(document.getElementById("container"), {
topN: 12,
direction: "inbound",
subtitle: "Net Migration Rank",
});
ring.update(
{ id: "seoul", label: "Seoul", coord: [400, 300] },
[
{ id: "busan", label: "Busan", coord: [700, 400], value: 1200 },
{ id: "daegu", label: "Daegu", coord: [600, 350], value: 800 },
{ id: "incheon", label: "Incheon", coord: [350, 250], value: 650 },
]
);import { flowring } from "flowring";
const ring = flowring(document.getElementById("container"), {
// Data display
topN: 10,
total: 50000,
direction: "outbound",
subtitle: "Outbound Transfer Rank",
// Appearance
radiusFraction: 0.25,
arcThickness: 16,
colors: {
arc: "rgb(30, 120, 220)",
stroke: "white",
buffer: "black",
dim: "#aaaaaa",
arcByRank: (rank) => (rank < 3 ? "rgb(255, 80, 40)" : "rgb(30, 120, 220)"),
},
// Animation
animate: true,
blinkCount: 3,
blinkInterval: 150,
// Projection (for map usage)
projection: (coord) => {
const p = map.project(coord);
return { x: p.x, y: p.y };
},
// Callbacks
formatLabel: (datum, rank) => `#${rank} ${datum.label} (${datum.value.toLocaleString()})`,
formatTitle: (source, subtitle) => [source.label, subtitle],
onRender: (time) => console.log("rendered at", time),
onBlink: (opacities) => {
// Sync external elements with blink state
opacities.forEach((opacity, i) => {
document.querySelector(`.rank-${i}`)?.style.setProperty("opacity", String(opacity));
});
},
});
// source: the center region
const source = { id: "seoul", label: "Seoul", coord: [126.978, 37.566] };
// data: ranked counterparts (array order = rank order)
const data = [
{ id: "suwon", label: "Suwon", coord: [127.0, 37.27], value: 1200 },
{ id: "incheon", label: "Incheon", coord: [126.7, 37.45], value: 980 },
{ id: "goyang", label: "Goyang", coord: [126.83, 37.65], value: 750 },
// ...up to topN items
];
ring.update(source, data);
map.on("move", () => ring.render());No map needed. Coordinates are [x, y] screen pixels.
import { flowring } from "flowring";
const ring = flowring(document.getElementById("diagram"), {
topN: 8,
direction: "outbound",
subtitle: "Transfer Volume",
formatLabel: (d, rank) => `${d.label}: ${d.value}%`,
});
ring.update(
{ id: "hq", label: "Headquarters", coord: [400, 300] },
[
{ id: "east", label: "East Region", coord: [700, 200], value: 340 },
{ id: "west", label: "West Region", coord: [100, 200], value: 280 },
{ id: "south", label: "South Region", coord: [400, 550], value: 150 },
]
);Provide a projection callback to convert [lon, lat] to screen pixels.
import { flowring } from "flowring";
// map: your MapLibre GL instance
// overlayDiv: a div positioned over the map
const ring = flowring(overlayDiv, {
projection: (coord) => {
const p = map.project(coord);
return { x: p.x, y: p.y };
},
direction: "inbound",
animate: true,
blinkCount: 3,
});
// Re-render on map movement
map.on("move", () => ring.render());
// Update on hover
ring.update(
{ id: "seoul", label: "Seoul", coord: [126.978, 37.566] },
[
{ id: "suwon", label: "Suwon", coord: [127.0, 37.27], value: 1200 },
{ id: "incheon", label: "Incheon", coord: [126.7, 37.45], value: 980 },
]
);
// Cleanup
ring.destroy();Use FlowringIndicator component and useFlowring hook.
import { FlowringIndicator, useFlowring } from "flowring/react";
function MyMap() {
const mapRef = useRef(null);
const { blinkTime, resetBlink } = useFlowring();
// selectedRegion: { code, name } from your app state (e.g., on hover)
// allData: full ranked array from your data source
// filteredData: top N items to display
useEffect(() => {
resetBlink();
}, [selectedRegion, resetBlink]);
return (
<Map ref={mapRef}>
{selectedRegion && (
<FlowringIndicator
mapRef={mapRef}
source={{
id: selectedRegion.code,
label: selectedRegion.name,
coord: [selectedRegion.lon, selectedRegion.lat],
}}
data={filteredData.map((r) => ({
id: r.id,
label: r.name,
coord: [r.lon, r.lat] as [number, number],
value: r.count,
}))}
blinkTime={blinkTime}
total={allData.reduce((s, r) => s + r.count, 0)}
direction="inbound"
subtitle="Net Migration Rank"
animate={true}
blinkCount={3}
formatLabel={(d, rank) => `${d.label} ${d.value.toLocaleString()}`}
/>
)}
</Map>
);
}Flowring draws the SVG overlay, but coloring regions on the map is up to you. Here's how to highlight the top ranked regions using deck.gl's GeoJsonLayer:
// geojson: your region boundaries FeatureCollection
// selectedRegionCode: code of the hovered source region
// rankMap: Map<code, rankIndex> built from flowring data
new GeoJsonLayer({
id: "region-highlight",
data: geojson,
filled: true,
stroked: false,
pickable: true,
getFillColor: (d) => {
const code = Number(d.properties.code);
// Green for the hovered source region
if (code === selectedRegionCode) return [23, 115, 58, 255];
// Yellow-green for ranked counterparts
if (rankMap.has(code)) return [174, 242, 2, 180];
// Transparent for the rest
return [0, 0, 0, 0];
},
updateTriggers: { getFillColor: [selectedRegionCode, rankMap] },
});When animate: true, the top ranks blink in the SVG overlay.
Use getBlinkOpacity to synchronize map fill colors with the same timing:
import { getBlinkOpacity } from "flowring";
// blinkTime: from useFlowring() hook or createBlinkTimer()
new GeoJsonLayer({
id: "region-highlight",
data: geojson,
filled: true,
getFillColor: (d) => {
const code = Number(d.properties.code);
if (code === selectedRegionCode) return [23, 115, 58, 255];
const rankIdx = rankMap.get(code);
if (rankIdx !== undefined) {
// Top 3 blink in sync with the SVG overlay
const opacity = rankIdx < 3 ? getBlinkOpacity(blinkTime, rankIdx) : 1;
return [174, 242, 2, 180 * opacity];
}
return [0, 0, 0, 0];
},
updateTriggers: { getFillColor: [selectedRegionCode, rankMap, blinkTime] },
});Creates a flowring instance.
Returns: FlowringInstance
| Option | Type | Default | Description |
|---|---|---|---|
topN |
number |
12 |
Max ranked items to display |
blinkCount |
number |
3 |
How many top ranks blink |
direction |
"inbound" | "outbound" |
"inbound" |
Arrow direction |
subtitle |
string |
"" |
Second line of title |
colors |
FlowringColors |
see below | Color overrides |
projection |
ProjectionFn |
undefined |
Coordinate-to-pixel projection |
radiusFraction |
number |
0.3 |
Donut radius as fraction of viewport |
arcThickness |
number |
20 |
Arc thickness in px |
total |
number |
sum of data | Grand total for ratio calc (prevents arcs filling 360°) |
animate |
boolean |
false |
Enable blink animation |
blinkInterval |
number |
150 |
Blink tick interval (ms) |
formatLabel |
(datum, rank) => string |
"label value" |
Custom label formatter |
formatTitle |
(source, subtitle) => string[] |
auto | Custom title formatter |
onRender |
(time) => void |
- | Called after each render |
onBlink |
(opacities) => void |
- | Called on blink tick |
| Key | Default | Description |
|---|---|---|
arc |
"rgb(242, 45, 10)" |
Donut arc fill |
stroke |
"black" |
Lines, arrows, rank circles |
buffer |
"white" |
Text halo |
dim |
"#666666" |
Leader lines |
arcByRank |
- | (rankIndex) => string per-rank override |
| Method | Description |
|---|---|
update(source, data) |
Set source + data, triggers render |
setOptions(opts) |
Partial merge options, triggers render |
render() |
Manual render (map pan/zoom) |
renderAt(time) |
Render at specific blink time |
svg() |
Get SVG element |
resize() |
Resize SVG to container |
destroy() |
Stop timers, remove SVG |
{ id: string | number; label: string; coord: [number, number] }{ id: string | number; label: string; coord: [number, number]; value: number }Blink is off by default. Enable with animate: true.
When enabled, the top blinkCount ranks (default 3) get a synchronized blink + bounce animation with a 6-second cycle.
For external sync (e.g., deck.gl fill colors), import getBlinkOpacity:
import { getBlinkOpacity, BLINK_CYCLE, BLINK_DELAYS } from "flowring";
// Returns 0 or 1
const opacity = getBlinkOpacity(elapsedSeconds, rankIndex);The examples/demo-map/ directory contains a full working demo using MapLibre, deck.gl, and flowring to visualize South Korea population migration data.
# 1. Clone the repository
git clone https://github.com/vuski/flowring.git
cd flowring
# 2. Install library dependencies and build
npm install
npm run build
# 3. Install demo app dependencies
cd examples/demo-map
npm install
# 4. Start the dev server
npm run devOpen http://localhost:3000 and hover over the map to see the visualization.
Or visit the Live Demo directly.
MIT