Комбинированный график
ComposedChart совмещает столбцы и линии на общей оси. Каждая серия в series — это { type: 'bar' } или { type: 'line' } со своими настройками. Без series первый ключ config становится столбцами, остальные — линиями.
Интерактивное демо
Пропсы
Заливка столбцов
Линия
Кривая
Точки
Свечение
Подсветка наведения
Ось Y
Загрузка
<script lang="ts" module>
import type {
BackgroundVariant,
BarVariant,
BrushOptions,
BrushRange,
DotVariant,
LegendOptions,
TooltipOptions
} from './types';
import type { CurveType } from './curves';
import type { StrokeVariant } from './chart-utils';
export type ComposedSeries =
| {
type: 'bar';
dataKey: string;
variant?: BarVariant;
radius?: number;
/** Soft neon glow around the bars. */
glow?: boolean;
isClickable?: boolean;
/** Dims every column except the hovered one. */
enableHoverHighlight?: boolean;
}
| {
type: 'line';
dataKey: string;
strokeVariant?: StrokeVariant;
curveType?: CurveType;
connectNulls?: boolean;
/** Soft neon glow around the line. */
glow?: boolean;
isClickable?: boolean;
dot?: DotVariant | false;
activeDot?: DotVariant | false;
};
</script>
<script lang="ts">
import ChartContainer from './ChartContainer.svelte';
import CartesianFrame from './CartesianFrame.svelte';
import ChartTooltip from './ChartTooltip.svelte';
import ChartLegend from './ChartLegend.svelte';
import ChartBrush from './ChartBrush.svelte';
import ChartDot from './ChartDot.svelte';
import ChartLoadingPattern from './ChartLoadingPattern.svelte';
import BarPatternDefs from './BarPatternDefs.svelte';
import SeriesColorGradient from './SeriesColorGradient.svelte';
import GlowFilter from './GlowFilter.svelte';
import { linePath } from './curves';
import {
computeBarSlots,
computeCartesianLayout,
computeValueTicks,
resolveOptions,
stackSeries,
type AxisOptions,
type GridOptions
} from './cartesian';
import {
roundedRectPath,
toNumber,
type ChartConfig,
type ChartRow,
type Margin,
toLabel
} from './chart-utils';
import { createLoadingData } from './loading.svelte';
type Props = {
data: ChartRow[];
config: ChartConfig;
/** Bars and lines, drawn in order. Defaults to: first key → bar, the rest → lines. */
series?: ComposedSeries[];
/** Category key for the x-axis, tooltip label and brush. */
xDataKey?: string;
class?: string;
curveType?: CurveType;
barGap?: number;
barCategoryGap?: number | string;
/** Defaults for bars / lines (overridden per series). */
barVariant?: BarVariant;
strokeVariant?: StrokeVariant;
dot?: DotVariant | false;
activeDot?: DotVariant | false;
isClickable?: boolean;
enableHoverHighlight?: boolean;
glow?: boolean;
grid?: boolean | GridOptions;
xAxis?: boolean | AxisOptions;
yAxis?: boolean | AxisOptions;
tooltip?: boolean | TooltipOptions;
legend?: boolean | LegendOptions;
brush?: boolean | BrushOptions;
backgroundVariant?: BackgroundVariant;
defaultSelectedDataKey?: string | null;
onSelectionChange?: (dataKey: string | null) => void;
isLoading?: boolean;
loadingBars?: number;
margin?: Margin;
ariaLabel?: string;
};
let {
data,
config,
series,
xDataKey,
class: className = '',
curveType = 'linear',
barGap = 4,
barCategoryGap = '10%',
barVariant = 'default',
strokeVariant = 'solid',
dot = false,
activeDot = false,
isClickable = false,
enableHoverHighlight = false,
glow = false,
grid = true,
xAxis = true,
yAxis = false,
tooltip = true,
legend = false,
brush = false,
backgroundVariant,
defaultSelectedDataKey = null,
onSelectionChange,
isLoading = false,
loadingBars = 12,
margin,
ariaLabel = 'Composed chart'
}: Props = $props();
const STROKE_WIDTH = 2;
const BAR_RADIUS = 4;
const chartId = $props.id();
// svelte-ignore state_referenced_locally
let selectedDataKey = $state<string | null>(defaultSelectedDataKey);
let activeIndex = $state<number | null>(null);
let pointer = $state<{ x: number; y: number } | null>(null);
// Resets to the full range whenever the data changes; the brush overrides it.
let range = $derived<BrushRange>({ startIndex: 0, endIndex: Math.max(0, data.length - 1) });
const loading = createLoadingData(() => isLoading, {
points: () => loadingBars,
min: 20,
max: 80
});
const gridOpts = $derived(resolveOptions<GridOptions>(grid, true));
const xAxisOpts = $derived(resolveOptions<AxisOptions>(xAxis, true));
const yAxisOpts = $derived(resolveOptions<AxisOptions>(yAxis, false));
const tooltipOpts = $derived(resolveOptions<TooltipOptions>(tooltip, true));
const legendOpts = $derived(resolveOptions<LegendOptions>(legend, false));
const brushOpts = $derived(resolveOptions<BrushOptions>(brush, false));
const categoryKey = $derived(xDataKey ?? xAxisOpts?.dataKey);
const resolvedSeries = $derived(
(
series ??
Object.keys(config).map(
(dataKey, i): ComposedSeries => (i === 0 ? { type: 'bar', dataKey } : { type: 'line', dataKey })
)
).map((s) =>
s.type === 'bar'
? {
type: 'bar' as const,
dataKey: s.dataKey,
variant: s.variant ?? barVariant,
radius: s.radius ?? BAR_RADIUS,
glow: s.glow ?? glow,
isClickable: s.isClickable ?? isClickable,
enableHoverHighlight: s.enableHoverHighlight ?? enableHoverHighlight,
strokeVariant: strokeVariant,
curveType: curveType,
connectNulls: false,
dot: false as const,
activeDot: false as const
}
: {
type: 'line' as const,
dataKey: s.dataKey,
variant: barVariant,
radius: BAR_RADIUS,
glow: s.glow ?? glow,
isClickable: s.isClickable ?? isClickable,
enableHoverHighlight: false,
strokeVariant: s.strokeVariant ?? strokeVariant,
curveType: s.curveType ?? curveType,
connectNulls: s.connectNulls ?? false,
dot: s.dot ?? dot,
activeDot: s.activeDot ?? activeDot
}
)
);
const keys = $derived(resolvedSeries.map((s) => s.dataKey));
const barKeys = $derived(resolvedSeries.filter((s) => s.type === 'bar').map((s) => s.dataKey));
const visibleData = $derived(
brushOpts && !isLoading ? data.slice(range.startIndex, range.endIndex + 1) : data
);
const rows = $derived(isLoading ? (loading.data as ChartRow[]) : visibleData);
const stacks = $derived(stackSeries(rows, isLoading ? ['loading'] : keys, 'default'));
const valueTicks = $derived(computeValueTicks(stacks, 'default', yAxisOpts));
function selectDataKey(key: string | null) {
selectedDataKey = key;
onSelectionChange?.(key);
}
function toggle(key: string) {
selectDataKey(selectedDataKey === key ? null : key);
}
function lineOpacity(key: string) {
if (selectedDataKey === null || selectedDataKey === key) return { stroke: 1, dot: 1 };
return { stroke: 0.3, dot: 0.3 };
}
function barOpacity(s: (typeof resolvedSeries)[number], index: number) {
const isSelected = selectedDataKey === null || selectedDataKey === s.dataKey;
const clickOpacity = s.isClickable && selectedDataKey !== null ? (isSelected ? 1 : 0.15) : 1;
if (s.enableHoverHighlight && activeIndex !== null) {
return activeIndex === index ? clickOpacity : clickOpacity * 0.3;
}
return clickOpacity;
}
function barFill(s: (typeof resolvedSeries)[number], sid: string) {
return s.variant === 'default'
? `url(#${sid}-colors-${s.dataKey})`
: `url(#${sid}-${s.variant}-${s.dataKey})`;
}
function categoryLabel(index: number) {
const raw = categoryKey ? rows[index]?.[categoryKey] : index;
const text = toLabel(raw);
return config[text]?.label ?? text;
}
</script>
<ChartContainer id={chartId} {config} class={className} {isLoading} hasFooter={!!brushOpts}>
{#snippet header()}
{#if legendOpts && (legendOpts.verticalAlign ?? 'top') === 'top'}
<ChartLegend {config} {keys} selected={selectedDataKey} onSelectChange={selectDataKey} options={legendOpts} />
{/if}
{/snippet}
{#snippet bottom()}
{#if legendOpts && legendOpts.verticalAlign === 'bottom'}
<ChartLegend {config} {keys} selected={selectedDataKey} onSelectChange={selectDataKey} options={legendOpts} />
{/if}
{/snippet}
{#snippet footer()}
{#if brushOpts && !isLoading}
<ChartBrush
{data}
{config}
{keys}
xDataKey={categoryKey}
variant="area"
{curveType}
options={brushOpts}
bind:range
class="mt-1"
/>
{/if}
{/snippet}
{#snippet children({ width, height }: { width: number; height: number })}
{@const layout = computeCartesianLayout({
width,
height,
rows,
categoryKey,
valueTicks,
categoryAxis: isLoading ? null : xAxisOpts,
valueAxis: isLoading ? null : yAxisOpts,
band: true,
margin
})}
{@const slots = computeBarSlots({
bandSize: layout.bandSize,
slots: isLoading ? 1 : Math.max(1, barKeys.length),
barGap,
barCategoryGap
})}
{@const barRect = (pair: [number, number], index: number, slot: number) => {
const a = layout.valueToPx(pair[0]);
const b = layout.valueToPx(pair[1]);
return {
x: layout.categoryPositions[index] + slots.offsetFor(slot),
y: Math.min(a, b),
width: slots.size,
height: Math.abs(b - a)
};
}}
<CartesianFrame
{width}
{height}
{layout}
grid={gridOpts}
{backgroundVariant}
cursor={!isLoading && tooltipOpts !== null && (tooltipOpts.cursor ?? true)}
defaultIndex={tooltipOpts?.defaultIndex}
bind:activeIndex
bind:pointer
{ariaLabel}
>
{#snippet defs()}
{#if isLoading}
<ChartLoadingPattern id={chartId} />
{:else}
{#each resolvedSeries as s, index (s.dataKey)}
{@const sid = `${chartId}-${index}`}
{#if s.type === 'bar'}
<SeriesColorGradient id={sid} dataKey={s.dataKey} item={config[s.dataKey]} direction="vertical" />
<BarPatternDefs id={sid} dataKey={s.dataKey} item={config[s.dataKey]} variant={s.variant} />
{#if s.glow}
<GlowFilter id="{sid}-glow" stdDeviation={8} alpha={0.5} region={100} />
{/if}
{:else}
<SeriesColorGradient id={sid} dataKey={s.dataKey} item={config[s.dataKey]} />
{#if s.glow}
<GlowFilter id="{sid}-glow" stdDeviation={10} alpha={2} region={50} />
{/if}
{/if}
{/each}
{/if}
{/snippet}
{#if isLoading}
<g style="mask: url(#{chartId}-loading-mask)" class="text-foreground">
{#each stacks[0] ?? [] as pair, i (i)}
{#if pair}
{@const r = barRect(pair, i, 0)}
<path
d={roundedRectPath(r.x, r.y, r.width, r.height, BAR_RADIUS)}
fill="currentColor"
fill-opacity="0.15"
/>
{/if}
{/each}
</g>
{:else}
{#each resolvedSeries as s, index (s.dataKey)}
{@const sid = `${chartId}-${index}`}
{#if s.type === 'bar'}
{@const slot = barKeys.indexOf(s.dataKey)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<g
style:cursor={s.isClickable || s.enableHoverHighlight ? 'pointer' : undefined}
onclick={() => {
if (s.isClickable) toggle(s.dataKey);
}}
>
{#each stacks[index] ?? [] as pair, i (i)}
{#if pair && toNumber(rows[i]?.[s.dataKey]) !== null}
{@const r = barRect(pair, i, slot)}
{#if s.variant === 'stripped'}
<g
filter={s.glow ? `url(#${sid}-glow)` : undefined}
opacity={barOpacity(s, i)}
class="transition-opacity duration-200"
>
<rect x={r.x} y={r.y} width={r.width} height={r.height} fill={barFill(s, sid)} />
<rect x={r.x} y={r.y} width={r.width} height={2} fill="url(#{sid}-colors-{s.dataKey})" />
</g>
{:else}
<path
d={roundedRectPath(r.x, r.y, r.width, r.height, s.radius)}
fill={barFill(s, sid)}
opacity={barOpacity(s, i)}
filter={s.glow ? `url(#${sid}-glow)` : undefined}
class="transition-opacity duration-200"
/>
{/if}
{/if}
{/each}
</g>
{:else}
{@const opacity = lineOpacity(s.dataKey)}
{@const points = rows.map((row, i) => {
const value = toNumber(row[s.dataKey]);
return value === null
? null
: { x: layout.categoryPositions[i], y: layout.valueToPx(value) };
})}
{@const d = linePath(points, s.curveType, s.connectNulls)}
{@const isAnimatedDashed = s.strokeVariant === 'animated-dashed'}
{@const isDashed = s.strokeVariant === 'dashed' || isAnimatedDashed}
<g>
{#if s.isClickable}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<path
{d}
fill="none"
stroke="transparent"
stroke-width="20"
style="cursor: pointer"
onclick={() => toggle(s.dataKey)}
/>
{/if}
<path
{d}
fill="none"
stroke="url(#{sid}-colors-{s.dataKey})"
stroke-opacity={opacity.stroke}
stroke-width={STROKE_WIDTH}
stroke-dasharray={isDashed ? '5 5' : undefined}
filter={s.glow ? `url(#${sid}-glow)` : undefined}
class="pointer-events-none"
>
{#if isAnimatedDashed && selectedDataKey === null}
<animate
attributeName="stroke-dasharray"
values="5 5; 0 5; 5 5"
dur="1s"
repeatCount="indefinite"
keyTimes="0;0.5;1"
/>
<animate
attributeName="stroke-dashoffset"
values="0; -10"
dur="1s"
repeatCount="indefinite"
keyTimes="0;1"
/>
{/if}
</path>
{#if s.dot}
{#each points as p, i (i)}
{#if p}
<ChartDot
cx={p.x}
cy={p.y}
variant={s.dot}
gradientUrl="url(#{sid}-colors-{s.dataKey})"
fillOpacity={opacity.dot}
class="pointer-events-none"
/>
{/if}
{/each}
{/if}
</g>
{/if}
{/each}
{#if activeIndex !== null}
{#each resolvedSeries as s, index (s.dataKey)}
{@const value = toNumber(rows[activeIndex]?.[s.dataKey])}
{#if s.type === 'line' && s.activeDot && value !== null}
<ChartDot
cx={layout.categoryPositions[activeIndex]}
cy={layout.valueToPx(value)}
variant={s.activeDot}
gradientUrl="url(#{chartId}-{index}-colors-{s.dataKey})"
fillOpacity={lineOpacity(s.dataKey).dot}
class="pointer-events-none"
/>
{/if}
{/each}
{/if}
{/if}
{#snippet tooltip({ index, x, y }: { index: number; x: number; y: number })}
{#if tooltipOpts && !isLoading}
<ChartTooltip
{x}
{y}
boundsWidth={width}
boundsHeight={height}
label={categoryLabel(index)}
options={tooltipOpts}
items={resolvedSeries
.filter((s) => rows[index]?.[s.dataKey] != null)
.map((s) => ({
key: s.dataKey,
name: config[s.dataKey]?.label ?? s.dataKey,
value: rows[index]?.[s.dataKey],
item: config[s.dataKey],
dimmed: selectedDataKey !== null && selectedDataKey !== s.dataKey
}))}
/>
{/if}
{/snippet}
</CartesianFrame>
{/snippet}
</ChartContainer>
<script lang="ts" module>
import type {
BackgroundVariant,
BarVariant,
BrushOptions,
BrushRange,
DotVariant,
LegendOptions,
TooltipOptions
} from './types';
import type { CurveType } from './curves';
import type { StrokeVariant } from './chart-utils';
export type ComposedSeries =
| {
type: 'bar';
dataKey: string;
variant?: BarVariant;
radius?: number;
/** Soft neon glow around the bars. */
glow?: boolean;
isClickable?: boolean;
/** Dims every column except the hovered one. */
enableHoverHighlight?: boolean;
}
| {
type: 'line';
dataKey: string;
strokeVariant?: StrokeVariant;
curveType?: CurveType;
connectNulls?: boolean;
/** Soft neon glow around the line. */
glow?: boolean;
isClickable?: boolean;
dot?: DotVariant | false;
activeDot?: DotVariant | false;
};
</script>
<script lang="ts">
import ChartContainer from './ChartContainer.svelte';
import CartesianFrame from './CartesianFrame.svelte';
import ChartTooltip from './ChartTooltip.svelte';
import ChartLegend from './ChartLegend.svelte';
import ChartBrush from './ChartBrush.svelte';
import ChartDot from './ChartDot.svelte';
import ChartLoadingPattern from './ChartLoadingPattern.svelte';
import BarPatternDefs from './BarPatternDefs.svelte';
import SeriesColorGradient from './SeriesColorGradient.svelte';
import GlowFilter from './GlowFilter.svelte';
import { linePath } from './curves';
import {
computeBarSlots,
computeCartesianLayout,
computeValueTicks,
resolveOptions,
stackSeries,
type AxisOptions,
type GridOptions
} from './cartesian';
import {
roundedRectPath,
toNumber,
type ChartConfig,
type ChartRow,
type Margin,
toLabel
} from './chart-utils';
import { createLoadingData } from './loading.svelte';
type Props = {
data: ChartRow[];
config: ChartConfig;
/** Bars and lines, drawn in order. Defaults to: first key → bar, the rest → lines. */
series?: ComposedSeries[];
/** Category key for the x-axis, tooltip label and brush. */
xDataKey?: string;
class?: string;
curveType?: CurveType;
barGap?: number;
barCategoryGap?: number | string;
/** Defaults for bars / lines (overridden per series). */
barVariant?: BarVariant;
strokeVariant?: StrokeVariant;
dot?: DotVariant | false;
activeDot?: DotVariant | false;
isClickable?: boolean;
enableHoverHighlight?: boolean;
glow?: boolean;
grid?: boolean | GridOptions;
xAxis?: boolean | AxisOptions;
yAxis?: boolean | AxisOptions;
tooltip?: boolean | TooltipOptions;
legend?: boolean | LegendOptions;
brush?: boolean | BrushOptions;
backgroundVariant?: BackgroundVariant;
defaultSelectedDataKey?: string | null;
onSelectionChange?: (dataKey: string | null) => void;
isLoading?: boolean;
loadingBars?: number;
margin?: Margin;
ariaLabel?: string;
};
let {
data,
config,
series,
xDataKey,
class: className = '',
curveType = 'linear',
barGap = 4,
barCategoryGap = '10%',
barVariant = 'default',
strokeVariant = 'solid',
dot = false,
activeDot = false,
isClickable = false,
enableHoverHighlight = false,
glow = false,
grid = true,
xAxis = true,
yAxis = false,
tooltip = true,
legend = false,
brush = false,
backgroundVariant,
defaultSelectedDataKey = null,
onSelectionChange,
isLoading = false,
loadingBars = 12,
margin,
ariaLabel = 'Composed chart'
}: Props = $props();
const STROKE_WIDTH = 2;
const BAR_RADIUS = 4;
const chartId = $props.id();
// svelte-ignore state_referenced_locally
let selectedDataKey = $state<string | null>(defaultSelectedDataKey);
let activeIndex = $state<number | null>(null);
let pointer = $state<{ x: number; y: number } | null>(null);
// Resets to the full range whenever the data changes; the brush overrides it.
let range = $derived<BrushRange>({ startIndex: 0, endIndex: Math.max(0, data.length - 1) });
const loading = createLoadingData(() => isLoading, {
points: () => loadingBars,
min: 20,
max: 80
});
const gridOpts = $derived(resolveOptions<GridOptions>(grid, true));
const xAxisOpts = $derived(resolveOptions<AxisOptions>(xAxis, true));
const yAxisOpts = $derived(resolveOptions<AxisOptions>(yAxis, false));
const tooltipOpts = $derived(resolveOptions<TooltipOptions>(tooltip, true));
const legendOpts = $derived(resolveOptions<LegendOptions>(legend, false));
const brushOpts = $derived(resolveOptions<BrushOptions>(brush, false));
const categoryKey = $derived(xDataKey ?? xAxisOpts?.dataKey);
const resolvedSeries = $derived(
(
series ??
Object.keys(config).map(
(dataKey, i): ComposedSeries => (i === 0 ? { type: 'bar', dataKey } : { type: 'line', dataKey })
)
).map((s) =>
s.type === 'bar'
? {
type: 'bar' as const,
dataKey: s.dataKey,
variant: s.variant ?? barVariant,
radius: s.radius ?? BAR_RADIUS,
glow: s.glow ?? glow,
isClickable: s.isClickable ?? isClickable,
enableHoverHighlight: s.enableHoverHighlight ?? enableHoverHighlight,
strokeVariant: strokeVariant,
curveType: curveType,
connectNulls: false,
dot: false as const,
activeDot: false as const
}
: {
type: 'line' as const,
dataKey: s.dataKey,
variant: barVariant,
radius: BAR_RADIUS,
glow: s.glow ?? glow,
isClickable: s.isClickable ?? isClickable,
enableHoverHighlight: false,
strokeVariant: s.strokeVariant ?? strokeVariant,
curveType: s.curveType ?? curveType,
connectNulls: s.connectNulls ?? false,
dot: s.dot ?? dot,
activeDot: s.activeDot ?? activeDot
}
)
);
const keys = $derived(resolvedSeries.map((s) => s.dataKey));
const barKeys = $derived(resolvedSeries.filter((s) => s.type === 'bar').map((s) => s.dataKey));
const visibleData = $derived(
brushOpts && !isLoading ? data.slice(range.startIndex, range.endIndex + 1) : data
);
const rows = $derived(isLoading ? (loading.data as ChartRow[]) : visibleData);
const stacks = $derived(stackSeries(rows, isLoading ? ['loading'] : keys, 'default'));
const valueTicks = $derived(computeValueTicks(stacks, 'default', yAxisOpts));
function selectDataKey(key: string | null) {
selectedDataKey = key;
onSelectionChange?.(key);
}
function toggle(key: string) {
selectDataKey(selectedDataKey === key ? null : key);
}
function lineOpacity(key: string) {
if (selectedDataKey === null || selectedDataKey === key) return { stroke: 1, dot: 1 };
return { stroke: 0.3, dot: 0.3 };
}
function barOpacity(s: (typeof resolvedSeries)[number], index: number) {
const isSelected = selectedDataKey === null || selectedDataKey === s.dataKey;
const clickOpacity = s.isClickable && selectedDataKey !== null ? (isSelected ? 1 : 0.15) : 1;
if (s.enableHoverHighlight && activeIndex !== null) {
return activeIndex === index ? clickOpacity : clickOpacity * 0.3;
}
return clickOpacity;
}
function barFill(s: (typeof resolvedSeries)[number], sid: string) {
return s.variant === 'default'
? `url(#${sid}-colors-${s.dataKey})`
: `url(#${sid}-${s.variant}-${s.dataKey})`;
}
function categoryLabel(index: number) {
const raw = categoryKey ? rows[index]?.[categoryKey] : index;
const text = toLabel(raw);
return config[text]?.label ?? text;
}
</script>
<ChartContainer id={chartId} {config} class={className} {isLoading} hasFooter={!!brushOpts}>
{#snippet header()}
{#if legendOpts && (legendOpts.verticalAlign ?? 'top') === 'top'}
<ChartLegend {config} {keys} selected={selectedDataKey} onSelectChange={selectDataKey} options={legendOpts} />
{/if}
{/snippet}
{#snippet bottom()}
{#if legendOpts && legendOpts.verticalAlign === 'bottom'}
<ChartLegend {config} {keys} selected={selectedDataKey} onSelectChange={selectDataKey} options={legendOpts} />
{/if}
{/snippet}
{#snippet footer()}
{#if brushOpts && !isLoading}
<ChartBrush
{data}
{config}
{keys}
xDataKey={categoryKey}
variant="area"
{curveType}
options={brushOpts}
bind:range
class="mt-1"
/>
{/if}
{/snippet}
{#snippet children({ width, height }: { width: number; height: number })}
{@const layout = computeCartesianLayout({
width,
height,
rows,
categoryKey,
valueTicks,
categoryAxis: isLoading ? null : xAxisOpts,
valueAxis: isLoading ? null : yAxisOpts,
band: true,
margin
})}
{@const slots = computeBarSlots({
bandSize: layout.bandSize,
slots: isLoading ? 1 : Math.max(1, barKeys.length),
barGap,
barCategoryGap
})}
{@const barRect = (pair: [number, number], index: number, slot: number) => {
const a = layout.valueToPx(pair[0]);
const b = layout.valueToPx(pair[1]);
return {
x: layout.categoryPositions[index] + slots.offsetFor(slot),
y: Math.min(a, b),
width: slots.size,
height: Math.abs(b - a)
};
}}
<CartesianFrame
{width}
{height}
{layout}
grid={gridOpts}
{backgroundVariant}
cursor={!isLoading && tooltipOpts !== null && (tooltipOpts.cursor ?? true)}
defaultIndex={tooltipOpts?.defaultIndex}
bind:activeIndex
bind:pointer
{ariaLabel}
>
{#snippet defs()}
{#if isLoading}
<ChartLoadingPattern id={chartId} />
{:else}
{#each resolvedSeries as s, index (s.dataKey)}
{@const sid = `${chartId}-${index}`}
{#if s.type === 'bar'}
<SeriesColorGradient id={sid} dataKey={s.dataKey} item={config[s.dataKey]} direction="vertical" />
<BarPatternDefs id={sid} dataKey={s.dataKey} item={config[s.dataKey]} variant={s.variant} />
{#if s.glow}
<GlowFilter id="{sid}-glow" stdDeviation={8} alpha={0.5} region={100} />
{/if}
{:else}
<SeriesColorGradient id={sid} dataKey={s.dataKey} item={config[s.dataKey]} />
{#if s.glow}
<GlowFilter id="{sid}-glow" stdDeviation={10} alpha={2} region={50} />
{/if}
{/if}
{/each}
{/if}
{/snippet}
{#if isLoading}
<g style="mask: url(#{chartId}-loading-mask)" class="text-foreground">
{#each stacks[0] ?? [] as pair, i (i)}
{#if pair}
{@const r = barRect(pair, i, 0)}
<path
d={roundedRectPath(r.x, r.y, r.width, r.height, BAR_RADIUS)}
fill="currentColor"
fill-opacity="0.15"
/>
{/if}
{/each}
</g>
{:else}
{#each resolvedSeries as s, index (s.dataKey)}
{@const sid = `${chartId}-${index}`}
{#if s.type === 'bar'}
{@const slot = barKeys.indexOf(s.dataKey)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<g
style:cursor={s.isClickable || s.enableHoverHighlight ? 'pointer' : undefined}
onclick={() => {
if (s.isClickable) toggle(s.dataKey);
}}
>
{#each stacks[index] ?? [] as pair, i (i)}
{#if pair && toNumber(rows[i]?.[s.dataKey]) !== null}
{@const r = barRect(pair, i, slot)}
{#if s.variant === 'stripped'}
<g
filter={s.glow ? `url(#${sid}-glow)` : undefined}
opacity={barOpacity(s, i)}
class="transition-opacity duration-200"
>
<rect x={r.x} y={r.y} width={r.width} height={r.height} fill={barFill(s, sid)} />
<rect x={r.x} y={r.y} width={r.width} height={2} fill="url(#{sid}-colors-{s.dataKey})" />
</g>
{:else}
<path
d={roundedRectPath(r.x, r.y, r.width, r.height, s.radius)}
fill={barFill(s, sid)}
opacity={barOpacity(s, i)}
filter={s.glow ? `url(#${sid}-glow)` : undefined}
class="transition-opacity duration-200"
/>
{/if}
{/if}
{/each}
</g>
{:else}
{@const opacity = lineOpacity(s.dataKey)}
{@const points = rows.map((row, i) => {
const value = toNumber(row[s.dataKey]);
return value === null
? null
: { x: layout.categoryPositions[i], y: layout.valueToPx(value) };
})}
{@const d = linePath(points, s.curveType, s.connectNulls)}
{@const isAnimatedDashed = s.strokeVariant === 'animated-dashed'}
{@const isDashed = s.strokeVariant === 'dashed' || isAnimatedDashed}
<g>
{#if s.isClickable}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<path
{d}
fill="none"
stroke="transparent"
stroke-width="20"
style="cursor: pointer"
onclick={() => toggle(s.dataKey)}
/>
{/if}
<path
{d}
fill="none"
stroke="url(#{sid}-colors-{s.dataKey})"
stroke-opacity={opacity.stroke}
stroke-width={STROKE_WIDTH}
stroke-dasharray={isDashed ? '5 5' : undefined}
filter={s.glow ? `url(#${sid}-glow)` : undefined}
class="pointer-events-none"
>
{#if isAnimatedDashed && selectedDataKey === null}
<animate
attributeName="stroke-dasharray"
values="5 5; 0 5; 5 5"
dur="1s"
repeatCount="indefinite"
keyTimes="0;0.5;1"
/>
<animate
attributeName="stroke-dashoffset"
values="0; -10"
dur="1s"
repeatCount="indefinite"
keyTimes="0;1"
/>
{/if}
</path>
{#if s.dot}
{#each points as p, i (i)}
{#if p}
<ChartDot
cx={p.x}
cy={p.y}
variant={s.dot}
gradientUrl="url(#{sid}-colors-{s.dataKey})"
fillOpacity={opacity.dot}
class="pointer-events-none"
/>
{/if}
{/each}
{/if}
</g>
{/if}
{/each}
{#if activeIndex !== null}
{#each resolvedSeries as s, index (s.dataKey)}
{@const value = toNumber(rows[activeIndex]?.[s.dataKey])}
{#if s.type === 'line' && s.activeDot && value !== null}
<ChartDot
cx={layout.categoryPositions[activeIndex]}
cy={layout.valueToPx(value)}
variant={s.activeDot}
gradientUrl="url(#{chartId}-{index}-colors-{s.dataKey})"
fillOpacity={lineOpacity(s.dataKey).dot}
class="pointer-events-none"
/>
{/if}
{/each}
{/if}
{/if}
{#snippet tooltip({ index, x, y }: { index: number; x: number; y: number })}
{#if tooltipOpts && !isLoading}
<ChartTooltip
{x}
{y}
boundsWidth={width}
boundsHeight={height}
label={categoryLabel(index)}
options={tooltipOpts}
items={resolvedSeries
.filter((s) => rows[index]?.[s.dataKey] != null)
.map((s) => ({
key: s.dataKey,
name: config[s.dataKey]?.label ?? s.dataKey,
value: rows[index]?.[s.dataKey],
item: config[s.dataKey],
dimmed: selectedDataKey !== null && selectedDataKey !== s.dataKey
}))}
/>
{/if}
{/snippet}
</CartesianFrame>
{/snippet}
</ChartContainer>
Использование
<script lang="ts">
import ComposedChart from '$lib/components/charts/ComposedChart.svelte';
</script>
<ComposedChart
{data}
{config}
xDataKey="month"
legend={{ isClickable: true }}
series={[
{ type: 'bar', dataKey: 'desktop', variant: 'duotone', enableHoverHighlight: true },
{ type: 'line', dataKey: 'mobile', strokeVariant: 'dashed', dot: 'border', glow: true }
]}
class="h-[320px] w-full"
/><script lang="ts">
import ComposedChart from '$lib/components/charts/ComposedChart.svelte';
</script>
<ComposedChart
{data}
{config}
xDataKey="month"
legend={{ isClickable: true }}
series={[
{ type: 'bar', dataKey: 'desktop', variant: 'duotone', enableHoverHighlight: true },
{ type: 'line', dataKey: 'mobile', strokeVariant: 'dashed', dot: 'border', glow: true }
]}
class="h-[320px] w-full"
/>Примеры
Две серии столбцов и линия
Десктоп
Мобильные
Всего
<ComposedChart {data} {config} xDataKey="month" legend
series={[
{ type: 'bar', dataKey: 'desktop', variant: 'hatched' },
{ type: 'bar', dataKey: 'mobile', variant: 'hatched' },
{ type: 'line', dataKey: 'total', dot: 'colored-border', curveType: 'monotone' }
]} /><ComposedChart {data} {config} xDataKey="month" legend
series={[
{ type: 'bar', dataKey: 'desktop', variant: 'hatched' },
{ type: 'bar', dataKey: 'mobile', variant: 'hatched' },
{ type: 'line', dataKey: 'total', dot: 'colored-border', curveType: 'monotone' }
]} />Свечение и stripped-столбцы
<ComposedChart {data} {config} xDataKey="month" barVariant="stripped" glow curveType="bump" dot="border" /><ComposedChart {data} {config} xDataKey="month" barVariant="stripped" glow curveType="bump" dot="border" />В markdown
Колонки из [lines] рисуются линиями, остальные — столбцами. Без [lines] первая колонка — столбцы, остальные — линии.
:::chart
[type]composed
[title]Выручка и прибыль
[variant]gradient
[lines]Прибыль
| Месяц | Выручка | Прибыль |
|-------|---------|---------|
| Янв | 8200 | 1400 |
| Фев | 9100 | 1700 |
| Мар | 10500 | 2100 |
| Апр | 9800 | 1900 |
| Май | 11200 | 2500 |
| Июн | 12400 | 2900 |
::::::chart
[type]composed
[title]Выручка и прибыль
[variant]gradient
[lines]Прибыль
| Месяц | Выручка | Прибыль |
|-------|---------|---------|
| Янв | 8200 | 1400 |
| Фев | 9100 | 1700 |
| Мар | 10500 | 2100 |
| Апр | 9800 | 1900 |
| Май | 11200 | 2500 |
| Июн | 12400 | 2900 |
:::Выручка и прибыль
Пропсы
| Пропс | Тип | По умолчанию | Описание |
|---|---|---|---|
data | ChartRow[] | — | Строки данных |
config | ChartConfig | — | Подписи и цвета серий |
series | ComposedSeries[] | первый ключ — bar, остальные — line | Столбцы и линии по порядку |
xDataKey | string | — | Ключ категории |
curveType | CurveType | 'linear' | Кривая линий |
barVariant | BarVariant | 'default' | Заливка столбцов по умолчанию |
strokeVariant | StrokeVariant | 'solid' | Стиль линий по умолчанию |
dot / activeDot | DotVariant false | false | Точки линий |
glow | boolean | false | Свечение всех серий |
enableHoverHighlight | boolean | false | Подсветка колонки под курсором |
isClickable | boolean | false | Выбор серии кликом |
barGap / barCategoryGap | number / number string | 4 / '10%' | Отступы столбцов |
grid, xAxis, yAxis, tooltip, legend, brush | — | как у AreaChart | Общие части графика |
backgroundVariant | BackgroundVariant | — | Декоративный фон |
isLoading | boolean | false | Скелетон загрузки |
class | string | — | Классы контейнера |
ComposedSeries
| Поле | Для | Описание |
|---|---|---|
type | — | 'bar' или 'line' |
dataKey | — | Ключ серии |
variant, radius, enableHoverHighlight | bar | Заливка, скругление (по умолчанию 4), подсветка |
strokeVariant, curveType, connectNulls, dot, activeDot | line | Стиль линии, кривая, пропуски, точки |
glow, isClickable | обе | Свечение и кликабельность |
