Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hot-dots-stick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

feat(Circle|Rect): Support passing children snippet for Html layers
2 changes: 1 addition & 1 deletion docs/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,5 @@ code[data-theme*=' '] span {

/* Inline code styling */
code:not(pre > code):not(.custom) {
@apply text-sm font-medium bg-surface-100 text-[initial] font-pixel px-2 rounded border border-primary/10;
@apply text-sm font-medium bg-primary/5 text-primary font-pixel px-2 rounded border border-primary/50;
}
116 changes: 116 additions & 0 deletions docs/src/lib/attachments/movable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
type MovableOptions = {
/**
* Number of pixels to step
*/
step?: number;

/**
* Percentage of parent element's pixels to step
*/
stepPercent?: number;

axis?: 'x' | 'y' | 'xy';

onMoveStart?: (opts: { x: number; y: number }) => void;
onMove?: (opts: { x: number; y: number; dx: number; dy: number }) => void;
onMoveEnd?: (opts: { x: number; y: number }) => void;
};

export function movable(options: MovableOptions = {}) {
return (node: HTMLElement | SVGElement) => {
let lastX = 0;
let lastY = 0;
let moved = false;

function onMouseDown(event: MouseEvent) {
lastX = event.clientX;
lastY = event.clientY;
moved = false;

options?.onMoveStart?.({ x: lastX, y: lastY });

window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
(node as HTMLElement).addEventListener('mousedown', onMouseDown);

function onMouseMove(event: MouseEvent) {
moved = true;

// TODO: Handle page scroll? clientX/Y is based on viewport (apply to parent?)
let dx = event.clientX - lastX;
let dy = event.clientY - lastY;

const xEnabled = options?.axis?.includes('x') ?? true;
const yEnabled = options?.axis?.includes('y') ?? true;

if (options.step) {
if (Math.abs(dx) >= options.step) {
const overStep = dx % options.step;
dx = dx - overStep;
lastX = event.clientX - overStep;
} else {
dx = 0;
}

if (Math.abs(dy) >= options.step) {
const overStep = dy % options.step;
dy = dy - overStep;
lastY = event.clientY - overStep;
} else {
dy = 0;
}
} else if (options.stepPercent) {
const parentWidth = node.parentElement?.offsetWidth ?? 0;
const parentHeight = node.parentElement?.offsetHeight ?? 0;

if (Math.abs(dx / parentWidth) >= options.stepPercent) {
const overStep = dx % (parentWidth * options.stepPercent);
dx = dx - overStep;
lastX = event.clientX - overStep;
} else {
dx = 0;
}

if (Math.abs(dy / parentHeight) >= options.stepPercent) {
const overStep = dy % (parentHeight * options.stepPercent);
dy = dy - overStep;
lastY = event.clientY - overStep;
} else {
dy = 0;
}
} else {
lastX = event.clientX;
lastY = event.clientY;
}

if ((xEnabled && dx) || (yEnabled && dy)) {
options.onMove?.({ x: lastX, y: lastX, dx: xEnabled ? dx : 0, dy: yEnabled ? dy : 0 });
} else {
// Not enough change
}
}

function onMouseUp(event: MouseEvent) {
lastX = event.clientX;
lastY = event.clientY;

options.onMoveEnd?.({ x: lastX, y: lastY });

window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
}

function onClick(event: MouseEvent) {
if (moved) {
event.stopImmediatePropagation();
}
}
(node as HTMLElement).addEventListener('click', onClick);

return () => {
(node as HTMLElement).removeEventListener('mousedown', onMouseDown);
(node as HTMLElement).removeEventListener('click', onClick);
};
};
}
7 changes: 5 additions & 2 deletions docs/src/lib/components/Code.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import SimpleIconsTerminal from '~icons/simple-icons/windowsterminal';
import SimpleIconsSvelte from '~icons/simple-icons/svelte';
import SimpleIconsHTML5 from '~icons/simple-icons/html5';
import { stripIndent } from '$lib/utils/string';

interface Props {
source?: string | null;
Expand All @@ -37,6 +38,8 @@
classes = {},
class: className
}: Props & HTMLAttributes<HTMLDivElement> = $props();

let sourceStr = $derived(stripIndent(source ?? ''));
</script>

<div class={cls('rounded-sm overflow-hidden', classes.root, className)}>
Expand Down Expand Up @@ -76,7 +79,7 @@
<div>Loading...</div>
{:then h}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html h.codeToHtml(source, {
{@html h.codeToHtml(sourceStr, {
lang: language,
themes: {
light: 'github-light-default',
Expand All @@ -98,7 +101,7 @@
)}
>
<CopyButton
value={source ?? ''}
value={sourceStr ?? ''}
class="text-surface-content/70 hover:bg-surface-100/20 py-1 backdrop-blur-md"
size="sm"
/>
Expand Down
22 changes: 22 additions & 0 deletions docs/src/lib/utils/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,25 @@ function getPixel(imageData: ImageData, x: number, y: number) {
const d = imageData.data;
return [d[i], d[i + 1], d[i + 2], d[i + 3]];
}

/**
* Remove leading indentation from multiline string (useful for template literals)
*/
export function stripIndent(text: string) {
const lines = text.split('\n');

// Find the minimum indentation (ignoring empty lines)
const minIndent = lines
.filter((line) => line.trim().length > 0)
.reduce((min, line) => {
const match = line.match(/^(\s*)/);
const indent = match ? match[1].length : 0;
return Math.min(min, indent);
}, Infinity);

// Remove the minimum indentation from all lines
return lines
.map((line) => line.slice(minIndent))
.filter((line) => line !== '')
.join('\n');
}
2 changes: 1 addition & 1 deletion docs/src/routes/docs/guides/layers/FeatureTable.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
{
name: 'feature',
header: 'Feature',
classes: { th: 'bg-surface-200', td: 'w-100 bg-surface-200' },
classes: { th: 'bg-surface-200', td: 'w-100 max-sm:bg-surface-200' },
sticky: {
left: true
}
Expand Down
16 changes: 16 additions & 0 deletions docs/src/routes/docs/guides/layers/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,5 +382,21 @@ export const other = [
webgl: {
support: true
}
},
{
feature: 'Marks can contain children',
html: {
support: true,
note: '<Rect>Example content</Rect>'
},
svg: {
support: false
},
canvas: {
support: false
},
webgl: {
support: false
}
}
];
131 changes: 105 additions & 26 deletions docs/src/routes/docs/guides/scales/+page.md
Original file line number Diff line number Diff line change
@@ -1,61 +1,140 @@
<script lang="ts">
import { scaleLinear } from 'd3-scale';
import { format } from '@layerstack/utils';

import Code from '$lib/components/Code.svelte';
import DomainRangeChart from './DomainRangeChart.svelte';

let domain = $state([100, 400]);
let range = $state([0, 500]);
let value = $state(250);

let scale = $derived(scaleLinear(domain, range));

let scaleExample = $derived(`
xScale(${domain[0]}) => ${scale(domain[0])};
xScale(${domain[1]}) => ${scale(domain[1])};
xScale(${value}) => ${format(scale(value), 'decimal')};
`);
</script>

# Scales

> WIP
## What is a scale?

At its essence, a scale is a function that maps data values (`domain`) to pixel or color values (`range`) on a per-dimension basis (`x`, `y`, `color`, etc).

## What is a scale
LayerChart uses [d3-scale](https://d3js.org/d3-scale) under the hood, which provides many different scale types including [`scaleLinear`](https://d3js.org/d3-scale/linear), [`scaleTime`](https://d3js.org/d3-scale/time), [`scaleBand`](https://d3js.org/d3-scale/band), and [many others](https://d3js.org/d3-scale).

At its essenece, a scale is a function that maps data values (`domain`) to pixel values (`range`) on a per-dimension basis (x, y, color, etc).
<DomainRangeChart bind:domain bind:range bind:value />

LayerChart uses [d3-scale](https://d3js.org/d3-scale) under the hood which provides many different scales (i.e. "mappers") including `scaleLinear`, `scaleTime`, `scaleBand`, and others.
In this interactive example, the **domain** (top bar) represents your data values ranging from <code>{domain[0]}</code> to <code>{domain[1]}</code>, while the **range** (bottom bar) represents the pixel values from <code>{range[0]}</code> to <code>{range[1]}</code>. The animated line shows how a domain value maps to its corresponding range value. Try dragging the edges to resize the domain/range, or mouse over to see how different values map between them.

![d3 scale image](https://jckr.github.io/blog/wp-content/uploads/2011/08/d3scale1.png)
### Creating a scale

this example basically says data/domain values are between `20` and `80` and range/pixels values are between `0` and `120`, and you would setup this (under the hood, what LayerChart does for you).
Under the hood, LayerChart creates scales for you, but here's what that looks like:

```js
const xScale = scaleLinear().domain([20, 80]).range([0, 120]);
const xScale = scaleLinear().domain(domain).range(range);
```

or shorthand
or using the shorthand syntax:

```js
const xScale = scaleLinear([20, 80], [0, 120]);
const xScale = scaleLinear(domain, range);
```

and would produce the following:
With the current values, this scale would produce:

- `xScale(20)` => `0`
- `xScale(80)` => `120`
- `xScale(50)` => `60`
<Code source={scaleExample} language="js" />

In LayerChart, range and domain are determined / defaulted for you
## Automatic scale selection

- `xDomain` => all `x` values in data (based on value accessor),
- `xRange` => `width` of chart (minus left/right padding),
- `yDomain` => all `y` values in data (based on value accessor),
- `yRange` => `height` of chart (minus top/bottom padding)
LayerChart intelligently selects the appropriate scale type based on your data. You don't need to explicitly specify whether to use `scaleLinear`, `scaleTime`, or `scaleBand` — LayerChart determines this automatically by inspecting your domain values or data.

for most scales, like scaleLinear, you specify the extents (min/max), which is why it is a 2 item array
The scale selection logic works as follows:

LayerChart will calcualte these for you, or you can pass an explicit value for one of both. If the other value is `null`, it will be calculated based on the data/chart dimensions
1. **`scaleTime`** — Used when domain or data contains `Date` objects
2. **`scaleLinear`** — Used when domain or data contains numbers
3. **`scaleBand`** — Used when domain or data contains strings (categorical data)

```js
For example:

```svelte
<!-- Automatically uses scaleTime for x-axis -->
<Chart data={timeSeriesData} x="date" y="value" />

<!-- Automatically uses scaleBand for x-axis -->
<Chart data={categoricalData} x="category" y="value" />

<!-- Automatically uses scaleLinear for both axes -->
<Chart data={numericData} x="temperature" y="pressure" />
```

You can override the automatic selection by explicitly passing a scale:

```svelte
<Chart xScale={scaleLog()} yScale={scaleSqrt()} />
```

## Automatic domain and range calculation

Within a `Chart`, the domain and range are automatically determined for you based on your data and chart dimensions:

- **`xDomain`**: The extent (min/max) of all `x` values in your data (based on the value accessor)
- **`xRange`**: The `width` of the chart (minus left/right padding)
- **`yDomain`**: The extent (min/max) of all `y` values in your data (based on the value accessor)
- **`yRange`**: The `height` of the chart (minus top/bottom padding)

For most continuous scales like `scaleLinear`, the domain and range are specified as two-element arrays representing the minimum and maximum values.

### Overriding domain and range

You can override the automatic calculation by providing explicit values. If you want LayerChart to calculate one of the extent values, pass `null` for that position:

```svelte
<Chart yDomain={[0, null]} />
```

This means also have a min extent of `0` regardless of your smallest data value, but calculate the max (by specifying `null`)
This sets a minimum y-domain value of `0` (regardless of your smallest data value) while letting LayerChart calculate the maximum value based on your data. This is useful when you want to ensure your y-axis always starts at zero.

You can also specify both values to have complete control:

```svelte
<Chart xDomain={[0, 100]} yDomain={[0, 1000]} />
```

## Scale types

LayerChart supports all d3-scale types:

- **Continuous scales**: Map continuous domains to continuous ranges
- [`scaleLinear`](https://d3js.org/d3-scale/linear): Linear mapping (most common)
- [`scaleLog`](https://d3js.org/d3-scale/log): Logarithmic mapping
- [`scalePow`](https://d3js.org/d3-scale/pow): Power (exponential) mapping
- [`scaleTime`](https://d3js.org/d3-scale/time): Time-based mapping with calendar intervals

- **Discrete scales**: Map discrete domains to discrete or continuous ranges
- [`scaleBand`](https://d3js.org/d3-scale/band): For bar charts and categorical data
- [`scalePoint`](https://d3js.org/d3-scale/point): For scatter plots with categorical data
- [`scaleOrdinal`](https://d3js.org/d3-scale/ordinal): General categorical mapping

this guarantees you always show `0` on your a-axis
- **Sequential scales**: Map continuous domains to interpolated color schemes
- [`scaleSequential`](https://d3js.org/d3-scale/sequential): For heatmaps and choropleth maps

See the [d3-scale documentation](https://d3js.org/d3-scale) for a complete reference.

## Ticks

https://observablehq.com/@d3/scale-ticks?collection=@d3/d3-scale
Scales provide a `.ticks()` method that generates human-readable reference values at regular intervals. LayerChart uses these for axis labels and grid lines. You can customize the number of ticks or provide explicit tick values.

Learn more: [Scale Ticks (Observable)](https://observablehq.com/@d3/scale-ticks?collection=@d3/d3-scale)

## Resources
## Useful resources

- [d3-scale documentation](https://d3js.org/d3-scale)
- [Introducing d3-scale](https://medium.com/@mbostock/introducing-d3-scale-61980c51545f 'https://medium.com/@mbostock/introducing-d3-scale-61980c51545f')
- [D3 in Depth: D3 Scale functions](https://www.d3indepth.com/scales/)
- [https://scottmurray.org/tutorials/d3/scales](https://scottmurray.org/tutorials/d3/scales 'https://scottmurray.org/tutorials/d3/scales')
- [https://jckr.github.io/blog/2011/08/11/d3-scales-and-color/](https://jckr.github.io/blog/2011/08/11/d3-scales-and-color/ 'https://jckr.github.io/blog/2011/08/11/d3-scales-and-color/')
- [d3-scale documentation](https://d3js.org/d3-scale)
- https://observablehq.com/@d3/continuous-scales?collection=@d3/d3-scale
Loading
Loading