Skip to content
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

canvas: mar 20 uxqa #6941

Merged
merged 8 commits into from
Mar 25, 2025
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
258 changes: 133 additions & 125 deletions web-common/src/components/time-series-chart/Chart.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
import { extent, max, min } from "d3-array";
import { scaleLinear, scaleTime } from "d3-scale";
import { DateTime, Interval } from "luxon";
import Crosshairs from "./Crosshairs.svelte";
import Line from "./Line.svelte";
import Point from "./Point.svelte";
import { portal } from "@rilldata/web-common/lib/actions/portal";

const SNAP_RANGE = 0.05;

Expand All @@ -30,6 +30,7 @@
};

let offsetPosition: { x: number; y: number } | null = null;
let clientPosition: { x: number; y: number } = { x: 0, y: 0 };
let contentRect = new DOMRectReadOnly(0, 0, 0, 0);
let yScale = scaleLinear();

Expand Down Expand Up @@ -57,33 +58,50 @@
$: mins = allYExtents.map((extents) => extents[0]).filter(isNumber);
$: maxes = allYExtents.map((extents) => extents[1]).filter(isNumber);

$: maxDataLength = Math.max(...mappedData.map((line) => line.length));

$: yExtents = [Math.min(0, min(mins) ?? 0), max(maxes) ?? 0];
$: yScale = yScale.domain(yExtents).range([100, 0]);
$: ySpan = yExtents[1] - yExtents[0];

$: hoverIndex =
offsetPosition === null
? null
: Math.floor((offsetPosition.x / width) * mappedData[0].length);
: Math.round((offsetPosition.x / width) * (maxDataLength - 1));

$: hoveredPoints = getPoints(hoverIndex);

$: snappedPoint =
!!offsetPosition?.y &&
hoveredPoints[0]?.value !== null &&
hoveredPoints[0]?.value !== undefined &&
Math.abs(
hoveredPoints[0]?.value -
yScale.invert((offsetPosition?.y / height) * 100),
) /
ySpan <
SNAP_RANGE &&
hoveredPoints[0];

$: svgCoordinateCursor = offsetPosition && {
x: (offsetPosition.x / width) * 1000,
y: (offsetPosition.y / height) * 100,
};
$: nearPoints = offsetPosition
? hoveredPoints
.map((point, index) => {
if (
point === null ||
point.value === null ||
point.value === undefined
)
return null;

if (
Math.abs(
point?.value -
yScale.invert(((offsetPosition?.y as number) / height) * 100),
) /
ySpan <
SNAP_RANGE
)
return {
point,
index,
};
return null;
})
.sort((a, b) => {
if (a === null) return 1;
if (b === null) return -1;

return (b.point?.value ?? 0) - (a.point?.value ?? 0);
})
: [];

function getColor(index: number) {
return index === 0 ? MainLineColor : "rgba(0, 0, 0, 0.22)";
Expand Down Expand Up @@ -114,118 +132,108 @@
value: point.records?.[yAccessor] as number | null | undefined,
} as MappedPoint;
}
</script>

function getPos(pos: number, width: number) {
const percentage = pos / width;
{#if mappedData.length}
<div role="presentation" class="flex flex-col grow h-full relative">
{#if nearPoints.filter(Boolean).length && clientPosition}
<div
use:portal
class=" w-fit label text-[10px] font-semibold flex flex-col z-[1000] shadow-sm bg-white text-gray-500 border-gray-200 -translate-y-1/2 py-0.5 border rounded-sm px-1 absolute pointer-events-none"
style:top="{clientPosition.y}px"
style:left="{clientPosition.x + 10}px"
>
{#each nearPoints as possiblePoint, i (i)}
{#if possiblePoint}
<div class="flex gap-x-1 items-center">
<span
class="size-[6.5px] rounded-full"
style:background-color={getColor(possiblePoint.index)}
/>
{formatterFunction(possiblePoint?.point.value)}
</div>
{/if}
{/each}
</div>
{/if}

if (percentage < 0.2) return "-right-0";
if (percentage > 0.8) return "-left-0";
<svg
bind:contentRect
role="presentation"
class="cursor-default size-full overflow-visible"
preserveAspectRatio="none"
viewBox="0 0 1000 100"
on:mousemove={(e) => {
offsetPosition = { x: e.offsetX, y: e.offsetY };
clientPosition = { x: e.clientX, y: e.clientY };
}}
on:mouseleave={() => {
offsetPosition = null;
}}
>
{#each mappedData as mappedDataLine, i (i)}
<Line
data={mappedDataLine}
xScale={xScales[i]}
color={getColor(i)}
{yScale}
fill={i === 0}
strokeWidth={1}
/>
{/each}

if (percentage <= 0.5) return "-left-0";
return "-right-0";
}
</script>
<g>
{#each [...mappedData].reverse() as mappedDataLine, reversedIndex (reversedIndex)}
{@const i = mappedData.length - reversedIndex - 1}
{#each mappedDataLine as { interval, value }, pointIndex (pointIndex)}
{@const xScale = xScales[i]}
{#if value !== null && value !== undefined && (hoverIndex === pointIndex || (mappedDataLine[pointIndex - 1]?.value === null && mappedDataLine[pointIndex + 1]?.value === null))}
<Point
x={xScale(interval.start.toJSDate())}
y={yScale(value)}
color={getColor(i)}
/>
{/if}
{/each}
{/each}
</g>
</svg>

<div role="presentation" class="flex flex-col grow h-full relative">
{#if hoveredPoints.length > 0 && offsetPosition}
<div
class="{getPos(
offsetPosition.x,
width,
)} w-fit label text-[10px] bg-white border-dashed text-gray-500 border-gray-300 -translate-y-1/2 py-0.5 border rounded-sm px-1 font-medium absolute pointer-events-none"
style:top="{snappedPoint && snappedPoint.value
? (yScale(snappedPoint.value) / 100) * height
: offsetPosition.y}px"
class:!text-primary-500={!!snappedPoint}
class:border-primary-400={!!snappedPoint}
class:!font-semibold={!!snappedPoint}
class="w-full h-fit flex justify-between text-gray-500 mt-0.5 relative"
>
{formatterFunction(
snappedPoint
? snappedPoint.value
: yScale.invert((offsetPosition?.y / height) * 100),
)}
{#if hoveredPoints.length > 0}
<span
class="relative"
style:transform="translateX(-{xScales[0](
hoveredPoints[0].interval.start.toJSDate(),
) / 10}%)"
style:left="{xScales[0](hoveredPoints[0].interval.start.toJSDate()) /
10}%"
>
<RangeDisplay
interval={hoveredPoints[0].interval}
grain={timeGrain}
/>
</span>
{:else if mappedData.length}
{@const firstPoint = mappedData?.[0]?.[0]}
{@const lastPoint = mappedData?.[0]?.[mappedData?.[0]?.length - 1]}
{#if firstPoint && lastPoint}
<span>
{firstPoint.interval.start.toLocaleString({
month: "short",
day: "numeric",
})}
</span>
<span>
{lastPoint.interval.end.minus({ millisecond: 1 }).toLocaleString({
month: "short",
day: "numeric",
})}
</span>
{/if}
{/if}
</div>
{/if}

<svg
bind:contentRect
role="presentation"
class="cursor-default size-full overflow-visible"
preserveAspectRatio="none"
viewBox="0 0 1000 100"
on:mousemove={(e) => {
offsetPosition = { x: e.offsetX, y: e.offsetY };
}}
on:mouseleave={() => {
offsetPosition = null;
}}
>
{#each mappedData as mappedDataLine, i (i)}
<Line
data={mappedDataLine}
xScale={xScales[i]}
color={getColor(i)}
{yScale}
fill={i === 0}
strokeWidth={1}
/>
{/each}

<Crosshairs
snapped={!!snappedPoint}
cursor={snappedPoint
? {
x: xScales[0](snappedPoint.interval.start.toJSDate()),
y: snappedPoint.value ? yScale(snappedPoint.value) : 50,
}
: svgCoordinateCursor}
/>

<g>
{#each [...mappedData].reverse() as mappedDataLine, reversedIndex (reversedIndex)}
{@const i = mappedData.length - reversedIndex - 1}
{#each mappedDataLine as { interval, value }, pointIndex (pointIndex)}
{@const xScale = xScales[i]}
{#if value !== null && value !== undefined && (hoverIndex === pointIndex || (mappedDataLine[pointIndex - 1]?.value === null && mappedDataLine[pointIndex + 1]?.value === null))}
<Point
x={xScale(interval.start.toJSDate())}
y={yScale(value)}
color={getColor(i)}
/>
{/if}
{/each}
{/each}
</g>
</svg>

<div class="w-full h-fit flex justify-between text-gray-500 mt-0.5 relative">
{#if hoveredPoints.length > 0}
<span
class="relative"
style:transform="translateX(-{xScales[0](
hoveredPoints[0].interval.start.toJSDate(),
) / 10}%)"
style:left="{xScales[0](hoveredPoints[0].interval.start.toJSDate()) /
10}%"
>
<RangeDisplay interval={hoveredPoints[0].interval} grain={timeGrain} />
</span>
{:else}
<span>
{mappedData[0][0].interval.start.toLocaleString({
month: "short",
day: "numeric",
})}
</span>
<span>
{mappedData[0][mappedData[0].length - 1].interval.end
.minus({ millisecond: 1 })
.toLocaleString({
month: "short",
day: "numeric",
})}
</span>
{/if}
</div>
</div>
{/if}
8 changes: 3 additions & 5 deletions web-common/src/features/canvas/AddComponentDropdown.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -97,19 +97,17 @@
<div class="flex flex-col" role="presentation" on:mouseenter={onMouseEnter}>
{#each menuItems as { id, label, icon } (id)}
<DropdownMenu.Item
class="flex flex-row gap-x-2"
on:click={() => {
open = false;
if (id === "bar_chart") {
handleChartItemClick();
} else {
onItemClick(id);
}
}}
>
<div class="flex flex-row gap-x-2">
<svelte:component this={icon} />
{label}
</div>
<svelte:component this={icon} />
{label}
</DropdownMenu.Item>
{/each}
</div>
Expand Down
7 changes: 6 additions & 1 deletion web-common/src/features/canvas/CanvasComponent.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,12 @@
<ComponentHeader {title} {description} filters={componentFilters} />
{/if}
{#if renderer && rendererProperties}
<ComponentRenderer {renderer} {rendererProperties} {componentName} />
<ComponentRenderer
hasHeader={title || description}
{renderer}
{rendererProperties}
{componentName}
/>
{/if}
{:else}
<div class="size-full grid place-content-center">
Expand Down
45 changes: 24 additions & 21 deletions web-common/src/features/canvas/CanvasDashboardWrapper.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,31 +12,34 @@
$: ({ width: clientWidth } = contentRect);
</script>

{#if filtersEnabled}
<header
role="presentation"
on:click|self={onClick}
class="bg-background border-b py-4 px-2 w-full select-none"
>
<CanvasFilters />
</header>
{/if}
<main class="size-full flex flex-col dashboard-theme-boundary overflow-hidden">
{#if filtersEnabled}
<header
role="presentation"
class="bg-background border-b py-4 px-2 w-full h-fit select-none z-50 flex items-center justify-center"
on:click|self={onClick}
>
<CanvasFilters {maxWidth} />
</header>
{/if}

<div
role="presentation"
id="canvas-scroll-container"
class="size-full overflow-hidden overflow-y-auto p-2 pb-48 flex flex-col items-center bg-white select-none"
class:!cursor-grabbing={showGrabCursor}
>
<div
class="w-full h-fit flex dashboard-theme-boundary flex-col items-center row-container relative"
style:max-width={maxWidth + "px"}
style:min-width="420px"
bind:contentRect
role="presentation"
id="canvas-scroll-container"
class="size-full p-2 pb-48 flex flex-col items-center bg-white select-none overflow-auto"
class:!cursor-grabbing={showGrabCursor}
on:click|self={onClick}
>
<slot />
<div
class="w-full h-fit flex flex-col items-center row-container relative"
style:max-width="{maxWidth}px"
style:min-width="420px"
bind:contentRect
>
<slot />
</div>
</div>
</div>
</main>

<style>
div {
Expand Down
Loading
Loading