Skip to content

feat: add text.lineHeight + support css variable font sizes #115

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 11, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
10 changes: 10 additions & 0 deletions src/lib/helpers/getBaseStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,13 @@ export function maybeToPixel(cssKey: string, value: string | number) {
}
return value;
}

export function maybeFromPixel(value: string | number) {
return typeof value === 'string' && value.endsWith('px') ? +value.slice(0, -2) : value;
}

export function maybeFromRem(value: string | number, rootFontSize: number = 16) {
return typeof value === 'string' && value.endsWith('rem')
? +value.slice(0, -3) * rootFontSize
: value;
}
1 change: 1 addition & 0 deletions src/lib/helpers/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function resolveProp<T>(
) {
return datum[accessor] as T;
}

return isRawValue(accessor) ? accessor : _defaultValue;
}

Expand Down
112 changes: 10 additions & 102 deletions src/lib/marks/Text.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
* the line anchor for vertical position; top, bottom, or middle
*/
lineAnchor?: ConstantAccessor<'bottom' | 'top' | 'middle'>;
/**
* line height as multiplier of font size
* @default 1.2
*/
lineHeight?: ConstantAccessor<number>;
frameAnchor?: ConstantAccessor<
| 'bottom'
| 'top'
Expand All @@ -35,7 +40,6 @@
import { getContext, type Snippet } from 'svelte';
import GroupMultiple from './helpers/GroupMultiple.svelte';
import type {
PlotContext,
DataRecord,
BaseMarkProps,
ConstantAccessor,
Expand All @@ -46,11 +50,14 @@
import Mark from '../Mark.svelte';
import { sort } from '$lib/index.js';

import MultilineText from './helpers/MultilineText.svelte';

const DEFAULTS = {
fontSize: 12,
fontWeight: 500,
strokeWidth: 1.6,
frameAnchor: 'center',
lineHeight: 1.1,
...getContext<PlotDefaults>('svelteplot/_defaults').text
};

Expand All @@ -65,21 +72,12 @@
...markProps
});

const { getPlotState } = getContext<PlotContext>('svelteplot');
let plot = $derived(getPlotState());

const LINE_ANCHOR = {
bottom: 'auto',
middle: 'central',
top: 'hanging'
} as const;

const args = $derived(
sort({
data,
...options
})
);
) as TextMarkProps;
</script>

<Mark
Expand All @@ -101,99 +99,9 @@
<GroupMultiple class="text {className}" length={className ? 2 : args.data.length}>
{#each scaledData as d, i (i)}
{#if d.valid}
{@const title = resolveProp(args.title, d.datum, '')}
{@const frameAnchor = resolveProp(args.frameAnchor, d.datum)}
{@const isLeft =
frameAnchor === 'left' ||
frameAnchor === 'top-left' ||
frameAnchor === 'bottom-left'}
{@const isRight =
frameAnchor === 'right' ||
frameAnchor === 'top-right' ||
frameAnchor === 'bottom-right'}
{@const isTop =
frameAnchor === 'top' ||
frameAnchor === 'top-left' ||
frameAnchor === 'top-right'}
{@const isBottom =
frameAnchor === 'bottom' ||
frameAnchor === 'bottom-left' ||
frameAnchor === 'bottom-right'}
{@const [x, y] =
args.x != null && args.y != null
? [d.x, d.y]
: [
args.x != null
? d.x
: isLeft
? plot.options.marginLeft
: isRight
? plot.options.marginLeft + plot.facetWidth
: plot.options.marginLeft + plot.facetWidth * 0.5,
args.y != null
? d.y
: isTop
? plot.options.marginTop
: isBottom
? plot.options.marginTop + plot.facetHeight
: plot.options.marginTop + plot.facetHeight * 0.5
]}

{@const dx = +resolveProp(args.dx, d.datum, 0)}
{@const dy = +resolveProp(args.dy, d.datum, 0)}
{@const textLines = String(resolveProp(args.text, d.datum, '')).split('\n')}
{@const lineAnchor = resolveProp(
args.lineAnchor,
d.datum,
args.y != null ? 'middle' : isTop ? 'top' : isBottom ? 'bottom' : 'middle'
)}
{@const textClassName = resolveProp(args.textClass, d.datum, null)}

{@const [style, styleClass] = resolveStyles(
plot,
{ ...d, __tspanIndex: 0 },
{
fontSize: 12,
fontWeight: 500,
strokeWidth: 1.6,
textAnchor: isLeft ? 'start' : isRight ? 'end' : 'middle',
...args
},
'fill',
usedScales
)}

{#if textLines.length > 1}
<!-- multiline text-->
{@const fontSize = resolveProp(args.fontSize, d.datum) || 12}
<text
class={[textClassName]}
dominant-baseline={LINE_ANCHOR[lineAnchor]}
transform="translate({Math.round(x + dx)},{Math.round(
y +
dy -
(lineAnchor === 'bottom'
? textLines.length - 1
: lineAnchor === 'middle'
? (textLines.length - 1) * 0.5
: 0) *
fontSize
)})"
>{#each textLines as line, l (l)}<tspan
x="0"
dy={l ? fontSize : 0}
class={styleClass}
{style}>{line}</tspan
>{/each}{#if title}<title>{title}</title>{/if}</text>
{:else}
<!-- singleline text-->
<text
class={[textClassName, styleClass]}
dominant-baseline={LINE_ANCHOR[lineAnchor]}
transform="translate({Math.round(x + dx)},{Math.round(y + dy)})"
{style}
>{textLines[0]}{#if title}<title>{title}</title>{/if}</text>
{/if}
<MultilineText {textLines} {d} {args} {usedScales} />
{/if}
{/each}
</GroupMultiple>
Expand Down
158 changes: 158 additions & 0 deletions src/lib/marks/helpers/MultilineText.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<script lang="ts">
import { resolveProp, resolveStyles } from 'svelteplot/helpers/resolve';
import { getContext, type ComponentProps } from 'svelte';
import type { PlotContext, ScaledDataRecord, UsedScales } from 'svelteplot/types';
import type Text from '../Text.svelte';
import { CSS_VAR } from 'svelteplot/constants';
import { maybeFromPixel, maybeFromRem } from 'svelteplot/helpers/getBaseStyles';

const LINE_ANCHOR = {
bottom: 'auto',
middle: 'central',
top: 'hanging'
} as const;

const { getPlotState } = getContext<PlotContext>('svelteplot');
const plot = $derived(getPlotState());

let {
textLines,
d,
args,
usedScales
}: {
textLines: string[];
d: ScaledDataRecord;
args: ComponentProps<typeof Text>;
usedScales: UsedScales;
} = $props();

const title = $derived(resolveProp(args.title, d.datum, ''));
const frameAnchor = $derived(resolveProp(args.frameAnchor, d.datum));
const isLeft = $derived(
frameAnchor === 'left' || frameAnchor === 'top-left' || frameAnchor === 'bottom-left'
);
const isRight = $derived(
frameAnchor === 'right' || frameAnchor === 'top-right' || frameAnchor === 'bottom-right'
);
const isTop = $derived(
frameAnchor === 'top' || frameAnchor === 'top-left' || frameAnchor === 'top-right'
);
const isBottom = $derived(
frameAnchor === 'bottom' || frameAnchor === 'bottom-left' || frameAnchor === 'bottom-right'
);
const lineAnchor = $derived(
resolveProp(
args.lineAnchor,
d.datum,
args.y != null ? 'middle' : isTop ? 'top' : isBottom ? 'bottom' : 'middle'
)
);
const textClassName = $derived(resolveProp(args.textClass, d.datum, null));
const [x, y] = $derived(
args.x != null && args.y != null
? [d.x, d.y]
: [
args.x != null
? d.x
: isLeft
? plot.options.marginLeft
: isRight
? plot.options.marginLeft + plot.facetWidth
: plot.options.marginLeft + plot.facetWidth * 0.5,
args.y != null
? d.y
: isTop
? plot.options.marginTop
: isBottom
? plot.options.marginTop + plot.facetHeight
: plot.options.marginTop + plot.facetHeight * 0.5
]
);

const dx = $derived(+resolveProp(args.dx, d.datum, 0));
const dy = $derived(+resolveProp(args.dy, d.datum, 0));

const [style, styleClass] = $derived(
resolveStyles(
plot,
{ ...d, __tspanIndex: 0 },
{
fontSize: 12,
fontWeight: 500,
strokeWidth: 1.6,
textAnchor: isLeft ? 'start' : isRight ? 'end' : 'middle',
...args
},
'fill',
usedScales
)
);

const fontSize = $derived(
textLines.length > 1 ? (resolveProp(args.fontSize, d.datum) ?? 12) : 0
);
let textElement: SVGTextElement | null = $state(null);

const rootFontSize = $derived(
textElement?.ownerDocument?.documentElement && textLines.length > 1
? maybeFromPixel(getComputedStyle(textElement.ownerDocument.documentElement).fontSize)
: 14
);

const computedFontSize = $derived(
textElement && textLines.length > 1 && CSS_VAR.test(fontSize)
? maybeFromRem(
maybeFromPixel(
getComputedStyle(textElement).getPropertyValue(
`--${fontSize.match(CSS_VAR)[1]}`
)
),
rootFontSize
)
: fontSize
);

const lineHeight = $derived(
textLines.length > 1 ? (resolveProp(args.lineHeight, d.datum) ?? 1.2) : 0
);
</script>

{#if textLines.length > 1}
<!-- multiline text-->
<text
bind:this={textElement}
class={[textClassName]}
dominant-baseline={LINE_ANCHOR[lineAnchor]}
transform="translate({Math.round(x + dx)},{Math.round(
y +
dy -
(lineAnchor === 'bottom'
? textLines.length - 1
: lineAnchor === 'middle'
? (textLines.length - 1) * 0.5
: 0) *
computedFontSize *
lineHeight
)})"
>{#each textLines as line, l (l)}<tspan
x="0"
dy={l ? computedFontSize * lineHeight : 0}
class={styleClass}
{style}>{line}</tspan
>{/each}{#if title}<title>{title}</title>{/if}</text>
{:else}
<!-- singleline text-->
<text
class={[textClassName, styleClass]}
dominant-baseline={LINE_ANCHOR[lineAnchor]}
transform="translate({Math.round(x + dx)},{Math.round(y + dy)})"
{style}
>{textLines[0]}{#if title}<title>{title}</title>{/if}</text>
{/if}

<style>
text {
paint-order: stroke fill;
}
</style>
29 changes: 29 additions & 0 deletions src/routes/examples/text/css-var.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script lang="ts" module>
export const sortKey = 99;
export const title = 'CSS variable font size';
export const description =
'The font size and line height are being used to position multiline text in SVG. You can also use CSS variables to set the font size (currently only in <tt>px</tt> and <tt>rem</tt>).';
</script>

<script lang="ts">
import { Plot, Text } from 'svelteplot';
import Frame from 'svelteplot/marks/Frame.svelte';
</script>

<Plot axes={false}>
<Frame borderRadius={20} strokeOpacity={0.25} />
<Text
fontSize="var(--my-font-size)"
fill="var(--svp-accent)"
fontWeight="bold"
text={`SveltePlot\nis great`} />

Check failure on line 19 in src/routes/examples/text/css-var.svelte

View workflow job for this annotation

GitHub Actions / lint

Unexpected mustache interpolation with a string literal value
</Plot>

<style>
:root {
--my-font-size: 3rem;
@media screen and (max-width: 600px) {
--my-font-size: 27px;
}
}
</style>
9 changes: 6 additions & 3 deletions src/routes/marks/text/+page.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,19 @@ Useful for showing text labels. The text mark is using SVG `<text>` elements, so

## Text options

The following channels are required:

- **text** - the text contents (a string, possibly with multiple lines)

You can position text either using [frameAnchor](#frame-anchor) or using _x_ and _y_ channels.

- **x** - the horizontal position; bound to the _x_ scale
- **y** - the vertical position; bound to the _y_ scale
- **frameAnchor** - if no x or y is given, the text can be positioned relative to the plot frame - `bottom`, `top`, `left`, `right`, `top-left`, `bottom-left`, `top-right`, `bottom-right`

- **dx** - horizontal offset in px
- **dy** - vertical offset in px
- **textAnchor** - `start`, `end`, or `middle`
- **lineAnchor** - `top`, `bottom` or `middle`
- **frameAnchor** - if no x or y is given, the text can be positioned relative to the plot frame - `bottom`, `top`, `left`, `right`, `top-left`, `bottom-left`, `top-right`, `bottom-right`
- **lineHeight** - expressed as factor of font size, default 1.1
- **class** - CSS class name to applied to the `<g>` around all texts
- **textClass** - CSS class to be applied to each `<text>` element, can be a function of data

Expand Down
Binary file modified static/examples/arrow/metro.dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/arrow/metro.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/axis/datawrapper-ticks.dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/axis/datawrapper-ticks.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/axis/major-minor.dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/axis/major-minor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/axis/tick-count.dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/axis/tick-count.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/axis/tick-interval.dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/axis/tick-interval.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/axis/tick-spacing.dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/axis/tick-spacing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/bar/defaults.dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/bar/defaults.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/bar/linked-bars.dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/bar/linked-bars.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/bar/shuffled-bars.dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/bar/shuffled-bars.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/brush/constrained.dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/brush/constrained.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/brush/filter.dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/brush/filter.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/brush/overview-detail.dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/brush/overview-detail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/brush/zoomable-scatter.dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified static/examples/brush/zoomable-scatter.png
Binary file modified static/examples/dot/0-scatterplot.dark.png
Binary file modified static/examples/dot/0-scatterplot.png
Binary file modified static/examples/dot/1-colored-scatterplot.dark.png
Binary file modified static/examples/dot/1-colored-scatterplot.png
Binary file modified static/examples/dot/2-symbol-channel.dark.png
Binary file modified static/examples/dot/2-symbol-channel.png
Binary file modified static/examples/dot/3-dot-plot.dark.png
Binary file modified static/examples/dot/3-dot-plot.png
Binary file modified static/examples/dot/bubble-matrix.dark.png
Binary file modified static/examples/dot/bubble-matrix.png
Binary file modified static/examples/geo/custom-proj.dark.png
Binary file modified static/examples/geo/custom-proj.png
Binary file modified static/examples/geo/earthquakes.dark.png
Binary file modified static/examples/geo/earthquakes.png
Binary file modified static/examples/geo/inset-aspect.dark.png
Binary file modified static/examples/geo/inset-aspect.png
Binary file modified static/examples/geo/us-choropleth-canvas.dark.png
Binary file modified static/examples/geo/us-choropleth-canvas.png
Binary file modified static/examples/geo/us-choropleth.dark.png
Binary file modified static/examples/geo/us-choropleth.png
Binary file modified static/examples/grid/clipped-gridlines.dark.png
Binary file modified static/examples/grid/clipped-gridlines.png
Binary file modified static/examples/line/apple-stock.dark.png
Binary file modified static/examples/line/apple-stock.png
Binary file modified static/examples/line/geo-line.dark.png
Binary file modified static/examples/line/geo-line.png
Binary file modified static/examples/line/gradient-line.dark.png
Binary file modified static/examples/line/gradient-line.png
Binary file modified static/examples/line/line-grouping.dark.png
Binary file modified static/examples/line/line-grouping.png
Binary file modified static/examples/line/tour-de-france.dark.png
Binary file modified static/examples/line/tour-de-france.png
Binary file modified static/examples/regression/cars.dark.png
Binary file modified static/examples/regression/cars.png
Binary file modified static/examples/regression/faceted.dark.png
Binary file modified static/examples/regression/faceted.png
Binary file modified static/examples/regression/grouped.dark.png
Binary file modified static/examples/regression/grouped.png
Binary file modified static/examples/regression/loess.dark.png
Binary file modified static/examples/regression/loess.png
Binary file modified static/examples/regression/log.dark.png
Binary file modified static/examples/regression/log.png
Binary file added static/examples/text/css-var.dark.png
Binary file added static/examples/text/css-var.png
Binary file added static/examples/text/frame-anchor.dark.png
Binary file added static/examples/text/frame-anchor.png
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy