From c1681bc7275c5266fd370b94d5cd4d97f78eeaaf Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Fri, 24 Oct 2025 09:28:16 -0400 Subject: [PATCH 1/5] Add Month component (WIP) --- .changeset/plain-rings-itch.md | 5 + .../src/lib/components/Month.svelte | 321 ++++++++++++++++ .../layerchart/src/lib/components/index.ts | 2 + .../layerchart/src/routes/_NavMenu.svelte | 1 + .../routes/docs/components/Month/+page.svelte | 346 ++++++++++++++++++ .../src/routes/docs/components/Month/+page.ts | 14 + 6 files changed, 689 insertions(+) create mode 100644 .changeset/plain-rings-itch.md create mode 100644 packages/layerchart/src/lib/components/Month.svelte create mode 100644 packages/layerchart/src/routes/docs/components/Month/+page.svelte create mode 100644 packages/layerchart/src/routes/docs/components/Month/+page.ts diff --git a/.changeset/plain-rings-itch.md b/.changeset/plain-rings-itch.md new file mode 100644 index 000000000..df28d97fd --- /dev/null +++ b/.changeset/plain-rings-itch.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +feat: Add Month component diff --git a/packages/layerchart/src/lib/components/Month.svelte b/packages/layerchart/src/lib/components/Month.svelte new file mode 100644 index 000000000..dc85e7eda --- /dev/null +++ b/packages/layerchart/src/lib/components/Month.svelte @@ -0,0 +1,321 @@ + + + + + + + {#if children} + {@render children({ cells: allCells, cellSize })} + {:else} + {#each allCells as cell} + tooltip?.show(e, cell.data)} + onpointerleave={(e) => tooltip?.hide()} + {...extractLayerProps(restProps, 'lc-month-cell')} + /> + + {#if showDayNumber} + + {/if} + {/each} + {/if} + + + {#if showMonthLabel} + {#each monthLabels as label} + + {/each} + {/if} + + + {#if showYearLabel} + {#each yearLabels as label} + + {/each} + {/if} + + + diff --git a/packages/layerchart/src/lib/components/index.ts b/packages/layerchart/src/lib/components/index.ts index 48e14a6fd..29618840c 100644 --- a/packages/layerchart/src/lib/components/index.ts +++ b/packages/layerchart/src/lib/components/index.ts @@ -93,6 +93,8 @@ export { default as Link } from './Link.svelte'; export * from './Link.svelte'; export { default as MotionPath } from './MotionPath.svelte'; export * from './MotionPath.svelte'; +export { default as Month } from './Month.svelte'; +export * from './Month.svelte'; export { default as Pack } from './Pack.svelte'; export * from './Pack.svelte'; export { default as Partition } from './Partition.svelte'; diff --git a/packages/layerchart/src/routes/_NavMenu.svelte b/packages/layerchart/src/routes/_NavMenu.svelte index 5faa8e2ac..a2159e980 100644 --- a/packages/layerchart/src/routes/_NavMenu.svelte +++ b/packages/layerchart/src/routes/_NavMenu.svelte @@ -91,6 +91,7 @@ 'Hull', 'Labels', 'Link', + 'Month', 'Pie', 'Points', 'Spline', diff --git a/packages/layerchart/src/routes/docs/components/Month/+page.svelte b/packages/layerchart/src/routes/docs/components/Month/+page.svelte new file mode 100644 index 000000000..02b18eaa1 --- /dev/null +++ b/packages/layerchart/src/routes/docs/components/Month/+page.svelte @@ -0,0 +1,346 @@ + + +

Examples

+ +

Single year

+ + +
+ + {#snippet children({ context })} + + + + + + {#snippet children({ data })} + + + {#if data.value != null} + + + + {/if} + {/snippet} + + {/snippet} + +
+
+ +

Multiple years (default)

+ + +
+ + {#snippet children({ context })} + + + + + + {#snippet children({ data })} + + + {#if data.value != null} + + + + {/if} + {/snippet} + + {/snippet} + +
+
+ + diff --git a/packages/layerchart/src/routes/docs/components/Month/+page.ts b/packages/layerchart/src/routes/docs/components/Month/+page.ts new file mode 100644 index 000000000..e90988ada --- /dev/null +++ b/packages/layerchart/src/routes/docs/components/Month/+page.ts @@ -0,0 +1,14 @@ +import api from '$lib/components/Month.svelte?raw&sveld'; +import source from '$lib/components/Month.svelte?raw'; +import pageSource from './+page.svelte?raw'; + +export async function load() { + return { + meta: { + api, + source, + pageSource, + supportedContexts: ['svg', 'canvas', 'html'], + }, + }; +} From 5b32c77f9d7b8f357c935a79b0d1bbeed0cdaa77 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Fri, 24 Oct 2025 09:47:59 -0400 Subject: [PATCH 2/5] Support start/end that do not align on year boundaries (ex. last 90 days) --- .../src/lib/components/Month.svelte | 184 +++++++----------- .../routes/docs/components/Month/+page.svelte | 134 +++---------- 2 files changed, 95 insertions(+), 223 deletions(-) diff --git a/packages/layerchart/src/lib/components/Month.svelte b/packages/layerchart/src/lib/components/Month.svelte index dc85e7eda..301ebfbf1 100644 --- a/packages/layerchart/src/lib/components/Month.svelte +++ b/packages/layerchart/src/lib/components/Month.svelte @@ -9,18 +9,14 @@ export type MonthPropsWithoutHTML = { /** - * The start year to display. - * - * @default current year + * The start date of the calendar. */ - startYear?: number; + start: Date; /** - * The end year to display (inclusive). - * - * @default current year + * The end date of the calendar. */ - endYear?: number; + end: Date; /** * Size of the cell in the calendar. @@ -55,29 +51,10 @@ */ showDayNumber?: boolean; - /** - * Whether to show month labels. - * - * @default true - */ - showMonthLabel?: boolean; - - /** - * Whether to show year labels. - * - * @default true - */ - showYearLabel?: boolean; - /** * Props to pass to the `` element for month labels. */ - monthLabelProps?: Partial>; - - /** - * Props to pass to the `` element for year labels. - */ - yearLabelProps?: Partial>; + monthLabel?: boolean | Partial>; /** * Props to pass to the `` element for day numbers. @@ -118,16 +95,14 @@ const DAYS_PER_WEEK = 7; let { - startYear = new Date().getFullYear(), - endYear = new Date().getFullYear(), + start, + end, cellSize = 25, + monthsPerRow: monthsPerRowProp, monthPadding = 1.2, rowSpacing = 8, showDayNumber = true, - showMonthLabel = true, - showYearLabel = true, - monthLabelProps = {}, - yearLabelProps = {}, + monthLabel = true, dayNumberProps = {}, tooltipContext: tooltip, children, @@ -136,16 +111,23 @@ const ctx = getChartContext(); + const rangeDays = $derived(timeDays(start, end)); + const rangeMonths = $derived(timeMonths(start, end)); + + // Space needed for month labels at the top + const monthLabelHeight = $derived(cellSize); + // Calculate monthsPerRow based on the actual space taken by each month // Each month (except the last in a row) takes: (monthPadding * cellSize * DAYS_PER_WEEK) // The calculation accounts for n-1 padded months plus one unpadded month // Formula: (n-1) * monthPadding * width + width = totalWidth // Solving for n: n = (totalWidth + (monthPadding - 1) * width) / (monthPadding * width) const monthsPerRow = $derived( - Math.floor( - (ctx.width + (monthPadding - 1) * cellSize * DAYS_PER_WEEK) / - (monthPadding * cellSize * DAYS_PER_WEEK) - ) + monthsPerRowProp ?? + Math.floor( + (ctx.width + (monthPadding - 1) * cellSize * DAYS_PER_WEEK) / + (monthPadding * cellSize * DAYS_PER_WEEK) + ) ); // Generate data indexed by date (using date object as key) @@ -153,86 +135,66 @@ ctx.data && ctx.config.x ? index(chartDataArray(ctx.data), (d) => ctx.x(d)) : new Map() ); - // Generate cells for all years + // Generate cells for the date range const allCells = $derived.by(() => { const cells: MonthCell[] = []; - - for (let year = startYear; year <= endYear; year++) { - const firstDayOfYear = new Date(year, 0, 1); - const lastDayOfYear = new Date(year + 1, 0, 1); - const yearDays = timeDays(firstDayOfYear, lastDayOfYear); - - yearDays.forEach((day) => { - const firstDayOfMonth = new Date(day.getFullYear(), day.getMonth(), 1); - const cellData = dataByDate.get(day) ?? { date: day }; - - const monthIndex = day.getMonth(); - const monthCol = monthIndex % monthsPerRow; - const monthRow = Math.floor(monthIndex / monthsPerRow); - - const monthPaddingOffset = monthPadding * cellSize * DAYS_PER_WEEK * monthCol; - const weekDiff = timeWeek.count(firstDayOfMonth, day); - const rowLevel = monthRow + 1; - - cells.push({ - x: day.getDay() * cellSize + monthPaddingOffset, - y: weekDiff * cellSize + rowLevel * cellSize * rowSpacing - cellSize / 2, - color: ctx.config.c ? ctx.cGet(cellData) : 'transparent', - data: cellData, - date: day, - }); + // Create a map of month index to track which months we've seen + const monthIndexMap = new Map(); + let currentMonthIndex = 0; + + rangeDays.forEach((day) => { + const firstDayOfMonth = new Date(day.getFullYear(), day.getMonth(), 1); + const monthKey = `${day.getFullYear()}-${day.getMonth()}`; + + // Assign a sequential index to each unique month in the range + if (!monthIndexMap.has(monthKey)) { + monthIndexMap.set(monthKey, currentMonthIndex); + currentMonthIndex++; + } + + const monthIndex = monthIndexMap.get(monthKey)!; + const cellData = dataByDate.get(day) ?? { date: day }; + + const monthCol = monthIndex % monthsPerRow; + const monthRow = Math.floor(monthIndex / monthsPerRow); + + const monthPaddingOffset = monthPadding * cellSize * DAYS_PER_WEEK * monthCol; + const weekDiff = timeWeek.count(firstDayOfMonth, day); + + cells.push({ + x: day.getDay() * cellSize + monthPaddingOffset, + y: weekDiff * cellSize + monthRow * cellSize * rowSpacing + monthLabelHeight, + color: ctx.config.c ? ctx.cGet(cellData) : 'transparent', + data: cellData, + date: day, }); - } + }); return cells; }); // Generate month labels const monthLabels = $derived.by(() => { - const labels: Array<{ x: number; y: number; text: string; year: number }> = []; - - for (let year = startYear; year <= endYear; year++) { - const firstDayOfYear = new Date(year, 0, 1); - const lastDayOfYear = new Date(year + 1, 0, 1); - const yearMonths = timeMonths(firstDayOfYear, lastDayOfYear); - - yearMonths.forEach((firstDayOfMonth) => { - const monthIndex = firstDayOfMonth.getMonth(); - const monthCol = monthIndex % monthsPerRow; - const monthRow = Math.floor(monthIndex / monthsPerRow); - - const monthPaddingOffset = monthPadding * cellSize * DAYS_PER_WEEK * monthCol; - const rowLevel = monthRow + 1; - - labels.push({ - x: monthPaddingOffset, - y: rowLevel * cellSize * rowSpacing - cellSize, - text: format(firstDayOfMonth, 'month'), - year, - }); - }); - } + const labels: Array<{ x: number; y: number; text: string }> = []; - return labels; - }); + rangeMonths.forEach((firstDayOfMonth, index) => { + const monthCol = index % monthsPerRow; + const monthRow = Math.floor(index / monthsPerRow); - // Calculate year label positions - const yearLabels = $derived.by(() => { - const labels: Array<{ x: number; y: number; text: string }> = []; + const monthPaddingOffset = monthPadding * cellSize * DAYS_PER_WEEK * monthCol; - for (let year = startYear; year <= endYear; year++) { labels.push({ - x: ctx.width / 2, - y: cellSize * 5.5, - text: String(year), + x: monthPaddingOffset, + y: monthRow * cellSize * rowSpacing, + text: format(firstDayOfMonth, 'month'), }); - } + }); return labels; }); - + {#if children} {@render children({ cells: allCells, cellSize })} @@ -265,27 +227,15 @@ {/if} - {#if showMonthLabel} + {#if monthLabel} {#each monthLabels as label} - {/each} - {/if} - - - {#if showYearLabel} - {#each yearLabels as label} - {/each} {/if} @@ -311,11 +261,7 @@ } :global(:where(.lc-month-month-label)) { - font-size: 1.1em; - } - - :global(:where(.lc-month-year-label)) { - font-size: 1.5em; + font-size: 16px; } } diff --git a/packages/layerchart/src/routes/docs/components/Month/+page.svelte b/packages/layerchart/src/routes/docs/components/Month/+page.svelte index 02b18eaa1..3c3dae09f 100644 --- a/packages/layerchart/src/routes/docs/components/Month/+page.svelte +++ b/packages/layerchart/src/routes/docs/components/Month/+page.svelte @@ -1,11 +1,13 @@

Examples

-

Single year

+

Current year

@@ -41,45 +46,7 @@ > {#snippet children({ context })} - - - - - {#snippet children({ data })} - - - {#if data.value != null} - - - - {/if} - {/snippet} - - {/snippet} - -
-
- -

Multiple years (default)

- - -
- - {#snippet children({ context })} - - + @@ -98,12 +65,10 @@
- From 81fa7db10b0135a9397b4e701a866e5a4017c718 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Fri, 24 Oct 2025 09:51:27 -0400 Subject: [PATCH 3/5] Refactor Month component to remove unused rangeMonths and enhance month label generation --- .../src/lib/components/Month.svelte | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/layerchart/src/lib/components/Month.svelte b/packages/layerchart/src/lib/components/Month.svelte index 301ebfbf1..6071d1a12 100644 --- a/packages/layerchart/src/lib/components/Month.svelte +++ b/packages/layerchart/src/lib/components/Month.svelte @@ -112,7 +112,6 @@ const ctx = getChartContext(); const rangeDays = $derived(timeDays(start, end)); - const rangeMonths = $derived(timeMonths(start, end)); // Space needed for month labels at the top const monthLabelHeight = $derived(cellSize); @@ -170,14 +169,22 @@ }); }); - return cells; + return { cells, monthIndexMap }; }); - // Generate month labels + // Generate month labels based on the actual months encountered in the cells const monthLabels = $derived.by(() => { const labels: Array<{ x: number; y: number; text: string }> = []; + const monthIndexMap = allCells.monthIndexMap; + + // Convert the map to an array of [monthKey, index] pairs and sort by index + const monthEntries = Array.from(monthIndexMap.entries()).sort((a, b) => a[1] - b[1]); + + monthEntries.forEach(([monthKey, index]) => { + // Parse the monthKey to get the year and month + const [year, month] = monthKey.split('-').map(Number); + const firstDayOfMonth = new Date(year, month, 1); - rangeMonths.forEach((firstDayOfMonth, index) => { const monthCol = index % monthsPerRow; const monthRow = Math.floor(index / monthsPerRow); @@ -197,9 +204,9 @@ {#if children} - {@render children({ cells: allCells, cellSize })} + {@render children({ cells: allCells.cells, cellSize })} {:else} - {#each allCells as cell} + {#each allCells.cells as cell} Date: Fri, 24 Oct 2025 09:54:24 -0400 Subject: [PATCH 4/5] Update monthLabelHeight calculation to conditionally account for label visibility --- packages/layerchart/src/lib/components/Month.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/layerchart/src/lib/components/Month.svelte b/packages/layerchart/src/lib/components/Month.svelte index 6071d1a12..ea5e40141 100644 --- a/packages/layerchart/src/lib/components/Month.svelte +++ b/packages/layerchart/src/lib/components/Month.svelte @@ -113,8 +113,8 @@ const rangeDays = $derived(timeDays(start, end)); - // Space needed for month labels at the top - const monthLabelHeight = $derived(cellSize); + // Space needed for month labels at the top (only if labels are shown) + const monthLabelHeight = $derived(monthLabel ? cellSize : 0); // Calculate monthsPerRow based on the actual space taken by each month // Each month (except the last in a row) takes: (monthPadding * cellSize * DAYS_PER_WEEK) From 12170d9c07a16f151ad6a59050c0fc40e7ce0104 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Fri, 24 Oct 2025 09:56:34 -0400 Subject: [PATCH 5/5] Update Month component to use current date as end date instead of last day of year --- .../layerchart/src/routes/docs/components/Month/+page.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/layerchart/src/routes/docs/components/Month/+page.svelte b/packages/layerchart/src/routes/docs/components/Month/+page.svelte index 3c3dae09f..35c43c42b 100644 --- a/packages/layerchart/src/routes/docs/components/Month/+page.svelte +++ b/packages/layerchart/src/routes/docs/components/Month/+page.svelte @@ -205,7 +205,7 @@ @@ -248,7 +248,7 @@