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/Annotations.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/Annotations.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...',
top: '18%',
left: '30%',
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: {
x: '28%',
y: '75%'
}
},
{
source: {
anchor: 'right-bottom',
dy: -7,
dx: 5
},
target: {
x: '68%',
y: '48%'
}
}]
}
];
data.forEach(d => {
d[yKey] = +d[yKey];
});
</script>
<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>
<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={data}
>
<Svg>
<Column/>
<AxisX
gridlines={false}
/>
<AxisY
gridlines={false}
/>
</Svg>
<Html>
<Annotations {annotations}/>
</Html>
<Svg>
<svelte:fragment slot="defs">
<ArrowheadMarker/>
</svelte:fragment>
<Arrows {annotations}/>
</Svg>
</LayerCake>
</div>
<!--
@component
Generates an SVG column chart.
-->
<script>
import { getContext } from 'svelte';
const { data, xGet, yGet, x, yRange, xScale, y, height, zGet, zScale, z } = 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} [gridlines=true] - Extend lines from the ticks into the chart space */
export let gridlines = true;
/** @type {Boolean} [tickMarks=false] - Show a vertical mark for each tick. */
export let tickMarks = false;
/** @type {Boolean} [baseline=false] – Show a solid line at the bottom. */
export let baseline = false;
/** @type {Boolean} [snapTicks=false] - Instead of centering the text on the first and the last items, align them to the edges of the chart. */
export let snapTicks = false;
/** @type {Function} [formatTick=d => d] - A function that passes the current tick value and expects a nicely formatted value in return. */
export let formatTick = d => d;
/** @type {Number|Array|Function} [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} [xTick=0] - How far over to position the text marker. */
export let xTick = 0;
/** @type {Number} [yTick=16] - The distance from the baseline to place each tick value. */
export let yTick = 16;
$: isBandwidth = typeof $xScale.bandwidth === 'function';
$: tickVals = Array.isArray(ticks) ? ticks :
isBandwidth ?
$xScale.domain() :
typeof ticks === 'function' ?
ticks($xScale.ticks()) :
$xScale.ticks(ticks);
function textAnchor(i) {
if (snapTicks === true) {
if (i === 0) {
return 'start';
}
if (i === tickVals.length - 1) {
return 'end';
}
}
return 'middle';
}
</script>
<g class="axis x-axis" class:snapTicks>
{#each tickVals as tick, i (tick)}
<g class="tick tick-{i}" transform="translate({$xScale(tick)},{Math.max(...$yRange)})">
{#if gridlines !== false}
<line class="gridline" y1={$height * -1} y2="0" x1="0" x2="0" />
{/if}
{#if tickMarks === true}
<line
class="tick-mark"
y1={0}
y2={6}
x1={isBandwidth ? $xScale.bandwidth() / 2 : 0}
x2={isBandwidth ? $xScale.bandwidth() / 2 : 0}
/>
{/if}
<text
x={isBandwidth ? ($xScale.bandwidth() / 2 + xTick) : xTick}
y={yTick}
dx=""
dy=""
text-anchor={textAnchor(i)}>{formatTick(tick)}</text
>
</g>
{/each}
{#if baseline === true}
<line class="baseline" y1={$height + 0.5} y2={$height + 0.5} x1="0" x2={$width} />
{/if}
</g>
<style>
.tick {
font-size: 0.725em;
font-weight: 200;
}
line,
.tick line {
stroke: #aaa;
stroke-dasharray: 2;
}
.tick text {
fill: #666;
}
.tick .tick-mark,
.baseline {
stroke-dasharray: 0;
}
/* This looks slightly better */
.axis.snapTicks .tick:last-child text {
transform: translateX(3px);
}
.axis.snapTicks .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 markers in the middle of the bandwidth.
-->
<script>
import { getContext } from 'svelte';
const { padding, xRange, yScale } = getContext('LayerCake');
/** @type {Boolean} [gridlines=true] - Extend lines from the ticks into the chart space */
export let gridlines = true;
/** @type {Boolean} [tickMarks=false] - Show a vertical mark for each tick. */
export let tickMarks = false;
/** @type {Function} [formatTick=d => d] - A function that passes the current tick value and expects a nicely formatted value in return. */
export let formatTick = d => d;
/** @type {Number|Array|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} [xTick=0] - How far over to position the text marker. */
export let xTick = 0;
/** @type {Number} [yTick=0] - How far up and down to position the text marker. */
export let yTick = 0;
/** @type {Number} [dxTick=0] - Any optional value passed to the `dx` attribute on the text marker and tick mark (if visible). This is ignored on the text marker if your scale is ordinal. */
export let dxTick = 0;
/** @type {Number} [dyTick=-4] - Any optional value passed to the `dy` attribute on the text marker and tick mark (if visible). This is ignored on the text marker if your scale is ordinal. */
export let dyTick = -4;
/** @type {String} [textAnchor='start'] The CSS `text-anchor` passed to the label. This is automatically set to "end" if the scale has a bandwidth method, like in ordinal scales. */
export let textAnchor = 'start';
$: isBandwidth = typeof $yScale.bandwidth === 'function';
$: tickVals = Array.isArray(ticks) ? ticks :
isBandwidth ?
$yScale.domain() :
typeof ticks === 'function' ?
ticks($yScale.ticks()) :
$yScale.ticks(ticks);
</script>
<g class='axis y-axis' transform='translate({-$padding.left}, 0)'>
{#each tickVals as tick (tick)}
<g class='tick tick-{tick}' transform='translate({$xRange[0] + (isBandwidth ? $padding.left : 0)}, {$yScale(tick)})'>
{#if gridlines !== false}
<line
class="gridline"
x2='100%'
y1={(isBandwidth ? ($yScale.bandwidth() / 2) : 0)}
y2={(isBandwidth ? ($yScale.bandwidth() / 2) : 0)}
></line>
{/if}
{#if tickMarks === true}
<line
class='tick-mark'
x1='0'
x2='{isBandwidth ? -6 : 6}'
y1={(isBandwidth ? ($yScale.bandwidth() / 2) : 0)}
y2={(isBandwidth ? ($yScale.bandwidth() / 2) : 0)}
></line>
{/if}
<text
x='{xTick}'
y='{(isBandwidth ? ($yScale.bandwidth() / 2) + yTick : yTick)}'
dx='{isBandwidth ? -9 : dxTick}'
dy='{isBandwidth ? 4 : dyTick}'
style="text-anchor:{isBandwidth ? 'end' : textAnchor};"
>{formatTick(tick)}</text>
</g>
{/each}
</g>
<style>
.tick {
font-size: .725em;
font-weight: 200;
}
.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 based on a config object that has CSS styles as fields.
-->
<script>
const vals = ['top', 'right', 'bottom', 'left'];
/** @type {Array} annotations - A list of annotation objects. It expects values of `top`, `right`, `bottom` and `left` whose values are CSS values like `'10px'` or `'5%'` that will be used to absolutely position the text div. See the [Column](https://layercake.graphics/example/Column) chart example for the schema and options. */
export let annotations = [];
/** @type {Function} [getText=d => d.text] - An accessor function to get the field to display. */
export let getText = d => d.text;
$: fillStyle = d => {
let style = '';
vals.forEach(val => {
if (d[val]) {
style += `${val}:${d[val]};`;
}
});
return style;
};
</script>
<div class="layercake-annotations">
{#each annotations as d, i}
<div
class="layercake-annotation"
data-id="{i}"
style="{fillStyle(d)}"
>{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 } 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 } = 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 = (anno, i, arrow) => '';
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(() => {
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
*/
const targetCoords = [arrow.target.x, arrow.target.y].map((q, j) => {
return parseCssValue(q, j, w, h);
});
/* --------------------------------------------
* 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