Column annotatedEdit
Since we want an ordinal x-axis and Layer Cake defaults to a linear scale, pass in a custom scale to xScale
with a few formatting options. Set the y-scale to always start at 0
so you don't show misleading differences between groups. For the annotation arrowhead, note that, depending on your app structure, you may need to provide an explicit link to your SVG marker id, such as in ./_components/Arrows.svelte
using window.location.href
.
- +page.svelte
- ./_components/Column.svelte
- ./_components/AxisX.svelte
- ./_components/AxisY.svelte
- ./_components/AnnotationsData.html.svelte
- ./_components/Arrows.svelte
- ./_components/ArrowheadMarker.svelte
- ./_modules/arrowUtils.js
- ./_data/groups.csv
<script>
import { LayerCake, Svg, Html } from 'layercake';
import { scaleBand } from 'd3-scale';
import Column from './_components/Column.svelte';
import AxisX from './_components/AxisX.svelte';
import AxisY from './_components/AxisY.svelte';
import Annotations from './_components/AnnotationsData.html.svelte';
import Arrows from './_components/Arrows.svelte';
import ArrowheadMarker from './_components/ArrowheadMarker.svelte';
// This example loads csv data as json using @rollup/plugin-dsv
import data from './_data/groups.csv';
const xKey = 'year';
const yKey = 'value';
const annotations = [
{
text: 'Example text...',
[xKey]: '1980',
[yKey]: 14,
dx: 15, // Optional pixel values
dy: -5,
arrows: [
{
clockwise: false, // true or false, defaults to true
source: {
anchor: 'left-bottom', // can be `{left, middle, right},{top-middle-bottom}`
dx: -2,
dy: -7
},
target: {
// These can be expressed in our data units if passed under the data keys
[xKey]: '1980',
[yKey]: 4.5,
// Optional adjustments
dx: 2,
dy: 5
}
},
{
source: {
anchor: 'right-bottom',
dy: -7,
dx: 5
},
target: {
// Or if they are percentage strings they can be passed directly
x: '68%',
y: '48%'
}
}
]
}
];
data.forEach(d => {
d[yKey] = +d[yKey];
});
</script>
<div class="chart-container">
<LayerCake
padding={{ top: 0, right: 0, bottom: 20, left: 20 }}
x={xKey}
y={yKey}
xScale={scaleBand().paddingInner(0.02).round(true)}
xDomain={['1979', '1980', '1981', '1982', '1983']}
yDomain={[0, null]}
{data}
>
<Svg>
<AxisX gridlines={false} />
<AxisY snapBaselineLabel />
<Column />
</Svg>
<Html>
<Annotations {annotations} />
</Html>
<Svg>
<svelte:fragment slot="defs">
<ArrowheadMarker />
</svelte:fragment>
<Arrows {annotations} />
</Svg>
</LayerCake>
</div>
<style>
/*
The wrapper div needs to have an explicit width and height in CSS.
It can also be a flexbox child or CSS grid element.
The point being it needs dimensions since the <LayerCake> element will
expand to fill it.
*/
.chart-container {
width: 100%;
height: 250px;
}
</style>
<!--
@component
Generates an SVG column chart.
-->
<script>
import { getContext } from 'svelte';
const { data, xGet, yGet, x, yRange, xScale, y, height } = getContext('LayerCake');
/** @type {String} [fill='#00e047'] - The shape's fill color. */
export let fill = '#00e047';
/** @type {String} [stroke='#000'] - The shape's stroke color. */
export let stroke = '#000';
/** @type {Number} [strokeWidth=0] - The shape's stroke width. */
export let strokeWidth = 0;
/** @type {boolean} [false] - Show the numbers for each column */
export let showLabels = false;
$: columnWidth = d => {
const vals = $xGet(d);
return Math.abs(vals[1] - vals[0]);
};
$: columnHeight = d => {
return $yRange[0] - $yGet(d);
};
</script>
<g class="column-group">
{#each $data as d, i}
{@const colHeight = columnHeight(d)}
{@const xGot = $xGet(d)}
{@const xPos = Array.isArray(xGot) ? xGot[0] : xGot}
{@const colWidth = $xScale.bandwidth ? $xScale.bandwidth() : columnWidth(d)}
{@const yValue = $y(d)}
<rect
class="group-rect"
data-id={i}
data-range={$x(d)}
data-count={yValue}
x={xPos}
y={$yGet(d)}
width={colWidth}
height={colHeight}
{fill}
{stroke}
stroke-width={strokeWidth}
/>
{#if showLabels && yValue}
<text x={xPos + colWidth / 2} y={$height - colHeight - 5} text-anchor="middle">{yValue}</text>
{/if}
{/each}
</g>
<style>
text {
font-size: 12px;
}
</style>
<!--
@component
Generates an SVG x-axis. This component is also configured to detect if your x-scale is an ordinal scale. If so, it will place the markers in the middle of the bandwidth.
-->
<script>
import { getContext } from 'svelte';
const { width, height, xScale, yRange } = getContext('LayerCake');
/** @type {boolean} [tickMarks=false] - Show a vertical mark for each tick. */
export let tickMarks = false;
/** @type {boolean} [gridlines=true] - Show gridlines extending into the chart area. */
export let gridlines = true;
/** @type {Number} [tickMarkLength=6] - The length of the tick mark. */
export let tickMarkLength = 6;
/** @type {boolean} [baseline=false] – Show a solid line at the bottom. */
export let baseline = false;
/** @type {boolean} [snapLabels=false] - Instead of centering the text labels on the first and the last items, align them to the edges of the chart. */
export let snapLabels = false;
/** @type {(d: any) => string} [format=d => d] - A function that passes the current tick value and expects a nicely formatted value in return. */
export let format = d => d;
/** @type {Number|Array<any>|Function|undefined} [ticks] - If this is a number, it passes that along to the [d3Scale.ticks](https://github.com/d3/d3-scale) function. If this is an array, hardcodes the ticks to those values. If it's a function, passes along the default tick values and expects an array of tick values in return. If nothing, it uses the default ticks supplied by the D3 function. */
export let ticks = undefined;
/** @type {Number} [tickGutter=0] - The amount of whitespace between the start of the tick and the chart drawing area (the yRange min). */
export let tickGutter = 0;
/** @type {Number} [dx=0] - Any optional value passed to the `dx` attribute on the text label. */
export let dx = 0;
/** @type {Number} [dy=12] - Any optional value passed to the `dy` attribute on the text label. */
export let dy = 12;
/**@param {Number} i
* @param {boolean} sl */
function textAnchor(i, sl) {
if (sl === true) {
if (i === 0) {
return 'start';
}
if (i === tickVals.length - 1) {
return 'end';
}
}
return 'middle';
}
$: tickLen = tickMarks === true ? tickMarkLength ?? 6 : 0;
$: isBandwidth = typeof $xScale.bandwidth === 'function';
/** @type {Array<any>} */
$: tickVals = Array.isArray(ticks)
? ticks
: isBandwidth
? $xScale.domain()
: typeof ticks === 'function'
? ticks($xScale.ticks())
: $xScale.ticks(ticks);
$: halfBand = isBandwidth ? $xScale.bandwidth() / 2 : 0;
</script>
<g class="axis x-axis" class:snapLabels>
{#each tickVals as tick, i (tick)}
{#if baseline === true}
<line class="baseline" y1={$height} y2={$height} x1="0" x2={$width} />
{/if}
<g class="tick tick-{i}" transform="translate({$xScale(tick)},{Math.max(...$yRange)})">
{#if gridlines === true}
<line class="gridline" x1={halfBand} x2={halfBand} y1={-$height} y2="0" />
{/if}
{#if tickMarks === true}
<line
class="tick-mark"
x1={halfBand}
x2={halfBand}
y1={tickGutter}
y2={tickGutter + tickLen}
/>
{/if}
<text x={halfBand} y={tickGutter + tickLen} {dx} {dy} text-anchor={textAnchor(i, snapLabels)}
>{format(tick)}</text
>
</g>
{/each}
</g>
<style>
.tick {
font-size: 11px;
}
line,
.tick line {
stroke: #aaa;
stroke-dasharray: 2;
}
.tick text {
fill: #666;
}
.tick .tick-mark,
.baseline {
stroke-dasharray: 0;
}
/* This looks slightly better */
.axis.snapLabels .tick:last-child text {
transform: translateX(3px);
}
.axis.snapLabels .tick.tick-0 text {
transform: translateX(-3px);
}
</style>
<!--
@component
Generates an SVG y-axis. This component is also configured to detect if your y-scale is an ordinal scale. If so, it will place the tickMarks in the middle of the bandwidth.
-->
<script>
import { getContext } from 'svelte';
const { xRange, yScale, width } = getContext('LayerCake');
/** @type {boolean} [tickMarks=false] - Show marks next to the tick label. */
export let tickMarks = false;
/** @type {String} [labelPosition='even'] - Whether the label sits even with its value ('even') or sits on top ('above') the tick mark. Default is 'even'. */
export let labelPosition = 'even';
/** @type {boolean} [snapBaselineLabel=false] - When labelPosition='even', adjust the lowest label so that it sits above the tick mark. */
export let snapBaselineLabel = false;
/** @type {boolean} [gridlines=true] - Show gridlines extending into the chart area. */
export let gridlines = true;
/** @type {Number|undefined} [tickMarkLength=undefined] - The length of the tick mark. If not set, becomes the length of the widest tick. */
export let tickMarkLength = undefined;
/** @type {(d: any) => string} [format=d => d] - A function that passes the current tick value and expects a nicely formatted value in return. */
export let format = d => d;
/** @type {Number|Array<any>|Function} [ticks=4] - If this is a number, it passes that along to the [d3Scale.ticks](https://github.com/d3/d3-scale) function. If this is an array, hardcodes the ticks to those values. If it's a function, passes along the default tick values and expects an array of tick values in return. */
export let ticks = 4;
/** @type {Number} [tickGutter=0] - The amount of whitespace between the start of the tick and the chart drawing area (the xRange min). */
export let tickGutter = 0;
/** @type {Number} [dx=0] - Any optional value passed to the `dx` attribute on the text label. */
export let dx = 0;
/** @type {Number} [dy=0] - Any optional value passed to the `dy` attribute on the text label. */
export let dy = 0;
/** @type {Number} [charPixelWidth=7.25] - Used to calculate the widest label length to offset labels. Adjust if the automatic tick length doesn't look right because you have a bigger font (or just set `tickMarkLength` to a pixel value). */
export let charPixelWidth = 7.25;
$: isBandwidth = typeof $yScale.bandwidth === 'function';
/** @type {Array<any>} */
$: tickVals = Array.isArray(ticks)
? ticks
: isBandwidth
? $yScale.domain()
: typeof ticks === 'function'
? ticks($yScale.ticks())
: $yScale.ticks(ticks);
/** @param {Number} sum
* @param {String} val */
function calcStringLength(sum, val) {
if (val === ',' || val === '.') return sum + charPixelWidth * 0.5;
return sum + charPixelWidth;
}
$: tickLen =
tickMarks === true
? labelPosition === 'above'
? tickMarkLength ?? widestTickLen
: tickMarkLength ?? 6
: 0;
$: widestTickLen = Math.max(
10,
Math.max(...tickVals.map(d => format(d).toString().split('').reduce(calcStringLength, 0)))
);
$: x1 = -tickGutter - (labelPosition === 'above' ? widestTickLen : tickLen);
$: y = isBandwidth ? $yScale.bandwidth() / 2 : 0;
$: maxTickValPx = Math.max(...tickVals.map($yScale));
</script>
<g class="axis y-axis">
{#each tickVals as tick (tick)}
{@const tickValPx = $yScale(tick)}
<g class="tick tick-{tick}" transform="translate({$xRange[0]}, {tickValPx})">
{#if gridlines === true}
<line class="gridline" {x1} x2={$width} y1={y} y2={y}></line>
{/if}
{#if tickMarks === true}
<line class="tick-mark" {x1} x2={x1 + tickLen} y1={y} y2={y}></line>
{/if}
<text
x={x1}
{y}
dx={dx + (labelPosition === 'even' ? -3 : 0)}
text-anchor={labelPosition === 'above' ? 'start' : 'end'}
dy={dy +
(labelPosition === 'above' || (snapBaselineLabel === true && tickValPx === maxTickValPx)
? -3
: 4)}>{format(tick)}</text
>
</g>
{/each}
</g>
<style>
.tick {
font-size: 11px;
}
.tick line {
stroke: #aaa;
}
.tick .gridline {
stroke-dasharray: 2;
}
.tick text {
fill: #666;
}
.tick.tick-0 line {
stroke-dasharray: 0;
}
</style>
<!--
@component
Adds text annotations that get their x and y placement using the `xScale` and `yScale`.
-->
<script>
import { getContext } from 'svelte';
const { xGet, yGet, percentRange } = getContext('LayerCake');
/** @type {Array} annotations - A list of annotation objects. */
export let annotations = [];
/** @type {Function} [getText=d => d.text] - An accessor function to get the field to display. */
export let getText = d => d.text;
/** @type {boolean} [percentRange=false] - If `true` will set the `top` and `left` CSS positions to percentages instead of pixels. */
export let pr = $percentRange;
$: units = pr === true ? '%' : 'px';
</script>
<div class="layercake-annotations">
{#each annotations as d, i}
<div
class="layercake-annotation"
data-id={i}
style:left={`calc(${$xGet(d)}${units} + ${d.dx || 0}px)`}
style:top={`calc(${$yGet(d)}${units} + ${d.dy || 0}px)`}
>
{getText(d)}
</div>
{/each}
</div>
<style>
.layercake-annotation {
position: absolute;
}
</style>
<!--
@component
Adds SVG swoopy arrows based on a config object. It attaches arrows to divs, which are created by another component such as [Annotations.html.svelte](https://layercake.graphics/components/Annotations.html.svelte).
-->
<script>
import { getContext, onMount, tick } from 'svelte';
import { swoopyArrow, getElPosition, parseCssValue } from '../_modules/arrowUtils.js';
/** @type {Array} annotations - A list of annotation objects. See the [Column](https://layercake.graphics/example/Column) chart example for the schema and options. */
export let annotations = [];
/** @type {String} [annotationClass='.layercake-annotation'] - The class name of the text annotation divs. */
export let containerClass = '.chart-container';
/** @type {String} [containerClass='.chart-container'] - The class name / CSS selector of the parent element of the `<LayerCake>` component. This is used to crawl the DOM for the text annotations. */
export let annotationClass = '.layercake-annotation';
let container;
const { width, height, xScale, yScale, x, y } = getContext('LayerCake');
/* --------------------------------------------
* Some lookups to convert between x, y / width, height terminology
* and CSS names
*/
const lookups = [
{ dimension: 'width', css: 'left', position: 'x' },
{ dimension: 'height', css: 'top', position: 'y' }
];
let d = () => '';
let annotationEls;
// This searches the DOM for the HTML annotations
// in the Annotations.svelte componenent and then
// attaches arrows to those divs
// Make sure the `.chart-container` and `.layercake-annotation`
// selectors match what you have in your project
// otherwise it won't find anything
onMount(async () => {
await tick();
annotationEls = Array.from(container.closest(containerClass).querySelectorAll(annotationClass));
});
function setPath(w, h) {
return (anno, i, arrow) => {
const el = annotationEls[i];
/* --------------------------------------------
* Parse our attachment directives to know where to start the arrowhead
* measuring a bounding box based on our annotation el
*/
const arrowSource = getElPosition(el);
const sourceCoords = arrow.source.anchor.split('-').map((q, j) => {
const point =
q === 'middle'
? arrowSource[lookups[j].css] + arrowSource[lookups[j].dimension] / 2
: arrowSource[q];
return (
point +
parseCssValue(
arrow.source[`d${lookups[j].position}`],
i,
arrowSource.width,
arrowSource.height
)
);
});
/* --------------------------------------------
* Default to clockwise
*/
const clockwise = typeof arrow.clockwise === 'undefined' ? true : arrow.clockwise;
/* --------------------------------------------
* Parse where we're drawing to
* If we're passing in a percentage as a string then we need to convert it to pixel values
* Otherwise pass it to our xGet and yGet functions
*/
const targetCoords = [
arrow.target.x || $x(arrow.target),
arrow.target.y || $y(arrow.target)
].map((q, j) => {
const val =
typeof q === 'string' && q.includes('%')
? parseCssValue(q, j, w, h)
: j
? $yScale(q)
: $xScale(q);
return val + (arrow.target[`d${lookups[j].position}`] || 0);
});
/* --------------------------------------------
* Create arrow path
*/
return swoopyArrow()
.angle(Math.PI / 2)
.clockwise(clockwise)
.x(q => q[0])
.y(q => q[1])([sourceCoords, targetCoords]);
};
}
$: if (annotationEls && annotationEls.length) d = setPath($width, $height);
</script>
<g bind:this={container}>
{#if annotations.length}
<g class="swoops">
{#each annotations as anno, i}
{#if anno.arrows}
{#each anno.arrows as arrow}
<path marker-end="url(#arrowhead)" d={d(anno, i, arrow)}></path>
{/each}
{/if}
{/each}
</g>
{/if}
</g>
<style>
.swoops {
position: absolute;
max-width: 200px;
line-height: 14px;
}
.swoops path {
fill: none;
stroke: #000;
stroke-width: 1;
}
</style>
<!--
@component
Generates an SVG marker containing a marker for a triangle makes a nice arrowhead. Add it to the named slot called "defs" on the SVG layout component.
-->
<script>
/** @type {String} [fill='#000'] – The arrowhead's fill color. */
export let fill = '#000';
/** @type {String} [stroke='#000'] – The arrowhead's fill color. */
export let stroke = '#000';
</script>
<marker id="arrowhead" viewBox="-10 -10 20 20" markerWidth="17" markerHeight="17" orient="auto">
<path d="M-6,-6 L 0,0 L -6,6" {fill} {stroke} />
</marker>
// Helper functions for creating swoopy arrows
/* --------------------------------------------
* parseCssValue
*
* Parse various inputs and return then as a number
* Can be a number, which will return the input value
* A percentage, which will take the percent of the appropriate dimentions
* A pixel value, which will parse as a number
*
*/
export function parseCssValue(d, i, width, height) {
if (!d) return 0;
if (typeof d === 'number') {
return d;
}
if (d.indexOf('%') > -1) {
return (+d.replace('%', '') / 100) * (i ? height : width);
}
return +d.replace('px', '');
}
/* --------------------------------------------
* getElPosition
*
* Constract a bounding box relative in our coordinate space
* that we can attach arrow starting points to
*
*/
export function getElPosition(el) {
const annotationBbox = el.getBoundingClientRect();
const parentBbox = el.parentNode.getBoundingClientRect();
const coords = {
top: annotationBbox.top - parentBbox.top,
right: annotationBbox.right - parentBbox.left,
bottom: annotationBbox.bottom - parentBbox.top,
left: annotationBbox.left - parentBbox.left,
width: annotationBbox.width,
height: annotationBbox.height
};
return coords;
}
/* --------------------------------------------
* swoopyArrow
*
* Adapted from bizweekgraphics/swoopyarrows
*
*/
export function swoopyArrow() {
let angle = Math.PI;
let clockwise = true;
let xValue = d => d[0];
let yValue = d => d[1];
function hypotenuse(a, b) {
return Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2));
}
function render(data) {
data = data.map((d, i) => {
return [xValue.call(data, d, i), yValue.call(data, d, i)];
});
// get the chord length ("height" {h}) between points
const h = hypotenuse(data[1][0] - data[0][0], data[1][1] - data[0][1]);
// get the distance at which chord of height h subtends {angle} radians
const d = h / (2 * Math.tan(angle / 2));
// get the radius {r} of the circumscribed circle
const r = hypotenuse(d, h / 2);
/*
SECOND, compose the corresponding SVG arc.
read up: http://www.w3.org/TR/SVG/paths.html#PathDataEllipticalArcCommands
example: <path d = "M 200,50 a 50,50 0 0,1 100,0"/>
M 200,50 Moves pen to (200,50);
a draws elliptical arc;
50,50 following a degenerate ellipse, r1 == r2 == 50;
i.e. a circle of radius 50;
0 with no x-axis-rotation (irrelevant for circles);
0,1 with large-axis-flag=0 and sweep-flag=1 (clockwise);
100,0 to a point +100 in x and +0 in y, i.e. (300,50).
*/
const path =
'M ' +
data[0][0] +
',' +
data[0][1] +
' a ' +
r +
',' +
r +
' 0 0,' +
(clockwise ? '1' : '0') +
' ' +
(data[1][0] - data[0][0]) +
',' +
(data[1][1] - data[0][1]);
return path;
}
render.angle = function renderAngle(_) {
if (!arguments.length) return angle;
angle = Math.min(Math.max(_, 1e-6), Math.PI - 1e-6);
return render;
};
render.clockwise = function renderClockwise(_) {
if (!arguments.length) return clockwise;
clockwise = !!_;
return render;
};
render.x = function renderX(_) {
if (!arguments.length) return xValue;
xValue = _;
return render;
};
render.y = function renderY(_) {
if (!arguments.length) return yValue;
yValue = _;
return render;
};
return render;
}
year,value 1979,2 1980,3 1981,5 1982,8 1983,18