diff --git a/apps/docs/src/app/page.tsx b/apps/docs/src/app/page.tsx index cf4330b..18ede22 100644 --- a/apps/docs/src/app/page.tsx +++ b/apps/docs/src/app/page.tsx @@ -13,7 +13,9 @@ import { Meta, Schema, MatrixFx, - Background + Background, + Pulse, + ShineFx } from "@once-ui-system/core"; import { baseURL, meta, schema, changelog, roadmap, layout } from "@/resources"; import { formatDate } from "./utils/formatDate"; @@ -89,7 +91,7 @@ export default function Home() { top="0" left="0" flicker - colors={["brand-solid-strong"]} + colors={["brand-solid-strong"]} bulge={{ type: "wave", duration: 3, @@ -101,20 +103,27 @@ export default function Home() { - NEW - - Once UI 1.5 — Curiosity in code - + + + + Once UI 1.5 Curiosity in code + New + + Once UI Docs diff --git a/apps/docs/src/content/once-ui/components/avatar.mdx b/apps/docs/src/content/once-ui/components/avatar.mdx index 90869b5..0572951 100644 --- a/apps/docs/src/content/once-ui/components/avatar.mdx +++ b/apps/docs/src/content/once-ui/components/avatar.mdx @@ -6,6 +6,8 @@ docs: "once-ui/components/avatar.mdx" github: "components/Avatar.tsx" navLabel: "Avatar" navIcon: "components" +navTag: "Update" +navTagVariant: "indigo" --- A versatile component for displaying user profile images, initials, or fallback icons in a circular container. Commonly used in user interfaces to represent people. @@ -34,6 +36,50 @@ A versatile component for displaying user profile images, initials, or fallback ]} /> +## Empty + +Use the `empty` prop to render an empty avatar. + + + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Empty" + } + ]} +/> + +## Icon + +Use the `icon` prop to render an icon in the avatar. + + + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Icon" + } + ]} +/> + ## Sizes Use the T-shirt size model or set sizes with numbers (REM). @@ -143,6 +189,7 @@ Use the T-shirt size model or set sizes with numbers (REM). ["size", ["xs", "s", "m", "l", "xl", "number"], "m"], ["value", "string"], ["src", "string"], + ["icon", "string"], ["loading", "boolean", "false"], ["empty", "boolean", "false"], ["statusIndicator", "object"], diff --git a/apps/docs/src/content/once-ui/components/media.mdx b/apps/docs/src/content/once-ui/components/media.mdx index 6792f5b..12198d0 100644 --- a/apps/docs/src/content/once-ui/components/media.mdx +++ b/apps/docs/src/content/once-ui/components/media.mdx @@ -16,6 +16,7 @@ navIcon: "components" preview={ `, @@ -49,6 +51,7 @@ Show a skeleton block while content is loading. Only works when `aspectRatio` is loading radius="l" aspectRatio="16/9" + sizes="(max-width: 768px) 100vw, 768px" src="/images/docs/once-ui/vibe-coding-light.jpg" /> } @@ -79,6 +82,7 @@ The `caption` prop renders a `ReactNode` below the image, making it easy to prov diff --git a/apps/docs/src/content/once-ui/components/segmentedControl.mdx b/apps/docs/src/content/once-ui/components/segmentedControl.mdx index 91bdb4d..483c6b1 100644 --- a/apps/docs/src/content/once-ui/components/segmentedControl.mdx +++ b/apps/docs/src/content/once-ui/components/segmentedControl.mdx @@ -6,6 +6,8 @@ docs: "once-ui/components/segmentedControl.mdx" github: "components/SegmentedControl.tsx" navLabel: "Segmented Control" navIcon: "components" +navTag: "Update" +navTagVariant: "indigo" --- Use this component for toggle-style selection between discrete options. It supports keyboard interaction, styling, and controlled or uncontrolled usage. @@ -41,9 +43,48 @@ Use this component for toggle-style selection between discrete options. It suppo ]} /> -## Default selected +## External state -Use `defaultSelected` to define an initial active button in uncontrolled mode. +Use `selected` to manage the selected state from the parent. + + + + + } + codes={[ + { + code: +` console.log(value)} +/>`, + language: "tsx", + label: "External state" + } + ]} +/> + +## Internal state + +Use `defaultSelected` to define an initial active button in uncontrolled mode. Don't use it along with the `selected` prop. console.log(value)} />`, language: "tsx", - label: "Default selected" + label: "Internal state" + } + ]} +/> + +## Compact + +Use `compact` to create a compact variant. + + + + + } + codes={[ + { + code: +` console.log(value)} +/>`, + language: "tsx", + label: "Compact" } ]} /> diff --git a/apps/docs/src/content/once-ui/data/linearGauge.mdx b/apps/docs/src/content/once-ui/data/linearGauge.mdx new file mode 100644 index 0000000..0b57734 --- /dev/null +++ b/apps/docs/src/content/once-ui/data/linearGauge.mdx @@ -0,0 +1,281 @@ +--- +title: "LinearGauge" +summary: "A horizontal, tick-based gauge for visualizing progress, metrics, and health with flexible label configurations." +updatedAt: "2025-11-16" +docs: "once-ui/data/linearGauge.mdx" +github: "modules/data/LinearGauge.tsx" +navLabel: "LinearGauge" +navIcon: "linearGauge" +navTag: "New" +navTagVariant: "cyan" +--- + +The `LinearGauge` component renders a horizontal tick-based gauge ideal for progress indicators, metrics dashboards, and system health displays. It supports flexible label configurations including none, percentage, or custom values. + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Usage" + } + ]} +/> + +### Labels + +Show percentage labels at key intervals (0%, 25%, 50%, 75%, 100%). + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Percentage" + } + ]} +/> + +Provide an array of custom labels distributed evenly across the gauge. + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Custom" + } + ]} +/> + +### Reverse order + +Flip the order of labels and gauge by using `direction="column-reverse"`. + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Reverse" + } + ]} +/> + +### Health status + +Use the `hue` prop to represent different states with color gradients. + + + + + + + + } + codes={[ + { + code: +` + + + + +`, + language: "tsx", + label: "States" + } + ]} +/> + +## Ticks + +Customize tick count, width, and length for different visual densities. + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Density" + } + ]} +/> + +## Props + +The `LinearGauge` component accepts all layout props from `Flex` (except `direction`) plus the following gauge-specific props: + + + +Use `LinearGauge` for horizontal metrics displays, progress tracking, or any scenario where a linear representation is more appropriate than a radial one. diff --git a/apps/docs/src/content/once-ui/data/meta.json b/apps/docs/src/content/once-ui/data/meta.json index 0637bbf..3bf69aa 100644 --- a/apps/docs/src/content/once-ui/data/meta.json +++ b/apps/docs/src/content/once-ui/data/meta.json @@ -7,6 +7,7 @@ "barChart": 3, "groupedBarChart": 4, "lineBarChart": 5, - "pieChart": 6 + "pieChart": 6, + "radialGauge": 7 } } \ No newline at end of file diff --git a/apps/docs/src/content/once-ui/data/radialGauge.mdx b/apps/docs/src/content/once-ui/data/radialGauge.mdx new file mode 100644 index 0000000..3fa015f --- /dev/null +++ b/apps/docs/src/content/once-ui/data/radialGauge.mdx @@ -0,0 +1,359 @@ +--- +title: "RadialGauge" +summary: "A radial, tick-based gauge for visualizing health metrics and real-time values using circular or arc-based layouts." +updatedAt: "2025-11-15" +docs: "once-ui/data/radialGauge.mdx" +github: "modules/data/RadialGauge.tsx" +navLabel: "RadialGauge" +navIcon: "radialGauge" +navTag: "New" +navTagVariant: "cyan" +--- + +The `RadialGauge` component renders a tick-based circular or arc-shaped gauge that is ideal for health metrics, KPIs, and other real-time values. It supports full circles, semicircles, and custom arcs with configurable angles, direction, density, and visual health states. + + + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Usage" + } + ]} +/> + +## Hue + +Use the `hue` prop to quickly change the color ramp of active ticks to represent different states. + + + + + + + + + + + + + + + + } + codes={[ + { + code: +` + + +`, + language: "tsx", + label: "Hue" + } + ]} +/> + +## Angles + +You can create semicircular or arc gauges by combining `angle` and `edgePad`. The angle system is **intuitive**: + +- `0`° = left +- `90`° = top +- `180`° = right +- `270`° = bottom + + + + + 68% + + + + } + codes={[ + { + code: +` + + 68% + +`, + language: "tsx", + label: "Angles" + } + ]} +/> + +## Line tick + +Control how many ticks are rendered and how long they are using `lineCount`, `lineWidth`, and `lineLength`. + + + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Density" + } + ]} +/> + +## Animation + +For interactive demos or dashboards, you can animate the `value` over time on the client. The example below simulates accelerating and braking. + + + + + } + codes={[ + { + code: +`"use client"; + +import React, { useEffect, useState } from "react"; +import { RadialGauge, Column, Text } from "@once-ui-system/core"; + +export function RadialGaugeSpeedDemo() { + const [value, setValue] = useState(20); + const [phase, setPhase] = useState<"idle" | "accelerate" | "brake">("idle"); + + useEffect(() => { + let timeout: ReturnType | undefined; + let interval: ReturnType | undefined; + + const startAccelerate = () => { + setPhase("accelerate"); + interval = setInterval(() => { + setValue(prev => { + const next = Math.min(prev + 6, 96); + if (next >= 96) { + clearInterval(interval); + timeout = setTimeout(startBrake, 600); + } + return next; + }); + }, 90); + }; + + const startBrake = () => { + setPhase("brake"); + interval = setInterval(() => { + setValue(prev => { + const next = Math.max(prev - 8, 18); + if (next <= 18) { + clearInterval(interval); + timeout = setTimeout(startAccelerate, 800); + } + return next; + }); + }, 90); + }; + + timeout = setTimeout(startAccelerate, 400); + + return () => { + if (interval) clearInterval(interval); + if (timeout) clearTimeout(timeout); + }; + }, []); + + const label = phase === "accelerate" ? "Accelerating" : phase === "brake" ? "Braking" : "Idle"; + + return ( + + + + {label} + + + ); +}`, + language: "tsx", + label: "Animated speed meter" + } + ]} +/> + +## Props + +The `RadialGauge` component accepts all layout props from `Column` (except `direction`) plus the following gauge-specific props: + + + +Use `RadialGauge` when you need a compact, highly legible representation of a single metric that can be scanned quickly, such as system health, SLO burn rate, or real-time utilization. diff --git a/apps/docs/src/content/once-ui/effects/celebrationFx.mdx b/apps/docs/src/content/once-ui/effects/celebrationFx.mdx new file mode 100644 index 0000000..ba77cd3 --- /dev/null +++ b/apps/docs/src/content/once-ui/effects/celebrationFx.mdx @@ -0,0 +1,390 @@ +--- +title: "CelebrationFx" +summary: "The CelebrationFx component creates animated celebration effects like confetti and fireworks using native Canvas 2D API." +updatedAt: "2025-11-16" +docs: "once-ui/effects/celebrationFx.mdx" +github: "components/CelebrationFx.tsx" +navLabel: "CelebrationFx" +navIcon: "sparkle" +navTag: "New" +navTagVariant: "cyan" +--- + +The `CelebrationFx` component creates animated celebration effects using the native Canvas 2D API. Choose between confetti or fireworks animations with multiple trigger modes, customizable colors, and intensity controls. Perfect for success states, achievements, milestones, or interactive celebrations. + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Usage" + } + ]} +/> + +## Celebration types + +Choose between `confetti` (falling particles) or `fireworks` (explosive bursts). + + + + + + } + codes={[ + { + code: +` +`, + language: "tsx", + label: "Types" + } + ]} +/> + +## Trigger + +Control when the celebration starts with `mount` (automatic), `hover` (on mouse enter), `click` (fires from click position), or `manual` (controlled via `active` prop). + + + + Hover me! + + + } + codes={[ + { + code: +` + + Hover me! + +`, + language: "tsx", + label: "Hover" + } + ]} +/> + +Use `trigger="click"` to fire celebrations from exactly where users click. Perfect for interactive celebrations! + + + + + Click for fireworks! + + + + + Click for confetti! + + + + } + codes={[ + { + code: +` + + Click for fireworks! + + + + + Click for confetti! + +`, + language: "tsx", + label: "Click" + } + ]} +/> + +## Color + +Customize celebration colors using design tokens or any valid CSS color. + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Colors" + } + ]} +/> + +### Intensity & speed + +Control particle density with `intensity` and animation speed with `speed`. + + + + + + } + codes={[ + { + code: +` +`, + language: "tsx", + label: "Intensity" + } + ]} +/> + +Control the celebration programmatically using `trigger="manual"` and the `active` prop. + + + } + codes={[ + { + code: +`"use client"; + +import React, { useState } from "react"; +import { CelebrationFx, Button, Column } from "@once-ui-system/core"; + +export function demo() { + const [active, setActive] = useState(false); + + return ( + + + + + ); +}`, + language: "tsx", + label: "Manual" + } + ]} +/> + +### Custom content + +Use `CelebrationFx` as a wrapper to overlay celebrations behind any content. + + + + + Achievement Unlocked! + + You've completed all challenges + + + + } + codes={[ + { + code: +` + + + Achievement Unlocked! + + You've completed all challenges + + +`, + language: "tsx", + label: "Overlay" + } + ]} +/> + +## Props + +The `CelebrationFx` component extends all layout props from `Flex` plus the following celebration-specific props: + + + +Use `CelebrationFx` to add delightful, performant celebration animations to success states, milestones, achievements, or any moment worth celebrating in your application. diff --git a/apps/docs/src/content/once-ui/effects/matrixFx.mdx b/apps/docs/src/content/once-ui/effects/matrixFx.mdx index 15cdefb..cf11f36 100644 --- a/apps/docs/src/content/once-ui/effects/matrixFx.mdx +++ b/apps/docs/src/content/once-ui/effects/matrixFx.mdx @@ -95,9 +95,9 @@ Use multiple colors for variety. Supports CSS variables and any valid color form ``, language: "tsx", @@ -271,10 +271,10 @@ Add dynamic wave effects to create fluid, organic motion. Choose between `ripple height={24} colors={["accent-solid-strong"]} bulge={{ - type: "ripple", // Circular wave from center - duration: 4, // Wave duration in seconds - intensity: 15, // Displacement strength - repeat: true // Continuous cycles + type: "ripple", // Circular wave from center + duration: 4, // Wave duration in seconds + intensity: 15, // Displacement strength + repeat: true // Continuous cycles }} />`, language: "tsx", @@ -308,10 +308,10 @@ The `wave` type creates an organic, flowing S-curve that travels diagonally from height={24} colors={["success-solid-strong"]} bulge={{ - type: "wave", // S-curve diagonal flow - duration: 3, - intensity: 20, - repeat: true + type: "wave", // S-curve diagonal flow + duration: 3, + intensity: 20, + repeat: true }} />`, language: "tsx", @@ -345,9 +345,9 @@ Use `repeat: false` for a single wave that plays once. Add `delay` for a pause b height={24} colors={["danger-solid-strong"]} bulge={{ - duration: 3, - intensity: 12, - repeat: false // Single wave only + duration: 3, + intensity: 12, + repeat: false // Single wave only }} />`, language: "tsx", @@ -356,14 +356,14 @@ Use `repeat: false` for a single wave that plays once. Add `delay` for a pause b ]} /> -## Hover +## Trigger Use `trigger="hover"` for native hover support. Dots progressively appear on hover and smoothly reverse when you move away. @@ -528,6 +528,72 @@ Use `trigger="hover"` for native hover support. Dots progressively appear on hov ]} /> +Use `trigger="click"` to toggle the effect on click. + + + + Click to toggle + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Click Trigger" + } + ]} +/> + +Use `trigger="manual"` with the `active` prop to control the effect from parent state. + + + } + codes={[ + { + code: +`"use client"; + +import React from "react"; +import { MatrixFx, Row, ToggleButton } from "@once-ui-system/core"; + +export function Demo() { + const [on, setOn] = React.useState(false); + return ( + + setOn((v) => !v)}> + {on ? "Stop" : "Start"} + + + + ); +} +`, + language: "tsx", + label: "Manual Trigger" + } + ]} +/> + ## API reference - - Fast shine (2s) + + Fast shine - default (1s) - Default shine (5s) + Medium shine (5s) Slow shine (10s) @@ -55,16 +55,16 @@ Adjust the animation speed with the `speed` prop. Higher values create slower an codes={[ { code: -` - Fast shine (2s) +` + Fast shine - default (1s) - - Default shine (5s) + + Medium shine (3s) - - Slow shine (10s) + + Slow shine (5s) `, language: "tsx", label: "Speed" @@ -72,6 +72,32 @@ Adjust the animation speed with the `speed` prop. Higher values create slower an ]} /> +## Inverse + +Use the `inverse` prop to invert the shine effect. + + + + Inverse shine + + + } + codes={[ + { + code: +` + Inverse shine +`, + language: "tsx", + label: "Inverse" + } + ]} +/> + ## Base opacity Control the base text visibility with the `baseOpacity` prop (0-1). Higher values improve readability and accessibility, while lower values create a more subtle effect. diff --git a/apps/docs/src/content/once-ui/effects/weatherFx.mdx b/apps/docs/src/content/once-ui/effects/weatherFx.mdx index 9070958..d386d56 100644 --- a/apps/docs/src/content/once-ui/effects/weatherFx.mdx +++ b/apps/docs/src/content/once-ui/effects/weatherFx.mdx @@ -1,6 +1,6 @@ --- title: "WeatherFx" -summary: "The WeatherFx component creates animated weather effects (rain, snow, leaves) using native Canvas 2D API." +summary: "The WeatherFx component creates animated weather effects (rain, snow, leaves, lightning) using native Canvas 2D API." updatedAt: "2025-10-25" docs: "once-ui/effects/WeatherFx.mdx" github: "components/WeatherFx.tsx" @@ -10,7 +10,7 @@ navTag: "New" navTagVariant: "cyan" --- -The `WeatherFx` component creates animated weather effects using the native Canvas 2D API. It supports rain, snow, and falling leaves with customizable colors, speed, and intensity. Perfect for backgrounds, hero sections, or thematic content areas. +The `WeatherFx` component creates animated weather effects using the native Canvas 2D API. It supports rain, snow, falling leaves, and lightning with customizable colors, speed, and intensity. Perfect for backgrounds, hero sections, or thematic content areas. Leaves + + + Lightning + + } codes={[ @@ -130,6 +141,13 @@ Switch between different weather effects: rain, snow, and falling leaves. colors={["warning-solid-strong", "danger-solid-strong"]} intensity={40} speed={0.7} +/> + +`, language: "tsx", label: "Types" @@ -669,6 +687,131 @@ Leaves feature gradient colors, rotation animation, and tumbling motion for an a ]} /> +## Lightning Effect + +Lightning features highly realistic bolt generation with chaotic, organic branching at all angles. Each bolt creates dense networks of branches spreading in multiple directions with up to 4 levels of hierarchy. The effect uses small, erratic segments and high branching probability to create the natural, fractal-like appearance of real lightning. Bolts animate from top to bottom with progressively thinner branches. The `intensity` prop controls both the frequency and number of simultaneous strikes. + + + + + Thunderstorm + + + Dynamic lightning with branching bolts + + + + } + codes={[ + { + code: +` + + + Thunderstorm + + + Dynamic lightning with branching bolts + + +`, + language: "tsx", + label: "Lightning Effect" + } + ]} +/> + +## Lightning Intensity + +For lightning, the `intensity` prop controls strike frequency and bolt patterns. The effect alternates between single and multi-bolt strikes for natural variation. Low intensity (1-15) creates single, rare strikes. Medium intensity (16-30) occasionally spawns double bolts. High intensity (31-60) alternates between single and 2-3 bolt strikes. Very high intensity (61+) creates dramatic storms alternating between single and 2-4 simultaneous bolts. + + + + + Rare (5) + + + + + Frequent (20) + + + + + Storm (50) + + + + } + codes={[ + { + code: +` + + + +`, + language: "tsx", + label: "Lightning Intensity" + } + ]} +/> + ## Duration Control how long particles are emitted with the `duration` prop (in seconds). After the duration, existing particles finish their animation but no new ones spawn. @@ -718,7 +861,7 @@ Control how long particles are emitted with the `duration` prop (in seconds). Af ]} /> -## Hover Trigger +## Trigger Use `trigger="hover"` to only emit particles when hovering over the element. @@ -779,17 +922,90 @@ Use `trigger="hover"` to only emit particles when hovering over the element. ]} /> +Use `trigger="click"` to toggle emission on click. + + + + Click to toggle rain + + + } + codes={[ + { + code: +``, + language: "tsx", + label: "Click Trigger" + } + ]} +/> + +Use `trigger="manual"` with the `active` prop to control emission from parent state. + + + } + codes={[ + { + code: +`"use client"; + +import React from "react"; +import { WeatherFx, Row, ToggleButton } from "@once-ui-system/core"; + +export function Demo() { + const [on, setOn] = React.useState(false); + + return ( + + setOn((v) => !v)}> + {on ? "Stop" : "Start"} + + + + ); +} +`, + language: "tsx", + label: "Manual Trigger" + } + ]} +/> + ## API reference + + + + ); +} diff --git a/apps/docs/src/product/MatrixFxExample.tsx b/apps/docs/src/product/MatrixFxExample.tsx new file mode 100644 index 0000000..af06233 --- /dev/null +++ b/apps/docs/src/product/MatrixFxExample.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { Row, MatrixFx, Button } from "@once-ui-system/core"; +import React from "react"; + +export function MatrixFxExample() { + const [on, setOn] = React.useState(false); + + return ( + + + + + ); +} diff --git a/apps/docs/src/product/RadialGaugeExample.tsx b/apps/docs/src/product/RadialGaugeExample.tsx new file mode 100644 index 0000000..d0410d7 --- /dev/null +++ b/apps/docs/src/product/RadialGaugeExample.tsx @@ -0,0 +1,44 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { RadialGauge } from "@once-ui-system/core"; + +// Animated speed meter demo for RadialGauge docs +export function RadialGaugeSpeedDemo() { + const [value, setValue] = useState(4); + + useEffect(() => { + const timeouts: NodeJS.Timeout[] = []; + + const cycle = () => { + timeouts.push(setTimeout(() => setValue(96), 500)); + timeouts.push(setTimeout(() => setValue(4), 2500)); + timeouts.push(setTimeout(cycle, 4500)); + }; + + cycle(); + + return () => { + timeouts.forEach(clearTimeout); + }; + }, []); + + return ( + + ); +} diff --git a/apps/docs/src/product/WeatherFxExample.tsx b/apps/docs/src/product/WeatherFxExample.tsx new file mode 100644 index 0000000..09920f3 --- /dev/null +++ b/apps/docs/src/product/WeatherFxExample.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { Button, Row } from "@once-ui-system/core"; +import { WeatherFx } from "@once-ui-system/core"; +import React from "react"; + +export function WeatherFxExample() { + const [on, setOn] = React.useState(false); + + return ( + + + + + ); +} \ No newline at end of file diff --git a/apps/docs/src/product/index.ts b/apps/docs/src/product/index.ts index 2f32099..9a1d50f 100644 --- a/apps/docs/src/product/index.ts +++ b/apps/docs/src/product/index.ts @@ -27,4 +27,9 @@ export * from "./SwitchExample"; export * from "./TextareaExamples"; export * from "./ToastExample"; export * from "./TypeFxCustomExample"; -export * from "./Products"; \ No newline at end of file +export * from "./Products"; +export * from "./RadialGaugeExample"; +export * from "./CelebrationFxExample"; +export * from "./WeatherFxExample"; +export * from "./MatrixFxExample"; + diff --git a/packages/core/package.json b/packages/core/package.json index bce62ff..a89ab50 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@once-ui-system/core", - "version": "1.5.2", + "version": "1.5.3", "description": "Once UI for Next.js NPM package", "keywords": [ "once-ui", diff --git a/packages/core/src/components/Avatar.tsx b/packages/core/src/components/Avatar.tsx index a20c32a..2476eaa 100644 --- a/packages/core/src/components/Avatar.tsx +++ b/packages/core/src/components/Avatar.tsx @@ -4,6 +4,7 @@ import React, { forwardRef } from "react"; import { Skeleton, Icon, Text, StatusIndicator, Flex, Media } from "."; import styles from "./Avatar.module.scss"; +import { IconName } from "@/icons"; interface AvatarProps extends React.ComponentProps { size?: "xs" | "s" | "m" | "l" | "xl" | number; @@ -11,6 +12,7 @@ interface AvatarProps extends React.ComponentProps { src?: string; loading?: boolean; empty?: boolean; + icon?: IconName; statusIndicator?: { color: "green" | "yellow" | "red" | "gray"; }; @@ -36,7 +38,7 @@ const statusIndicatorSizeMapping: Record<"xs" | "s" | "m" | "l" | "xl", "s" | "m const Avatar = forwardRef( ( - { size = "m", value, src, loading, empty, statusIndicator, className, style = {}, ...rest }, + { size = "m", value, src, loading, empty, icon, statusIndicator, className, style = {}, ...rest }, ref, ) => { const sizeInRem = typeof size === "number" ? `${size}rem` : undefined; @@ -75,7 +77,7 @@ const Avatar = forwardRef( return ( { + type?: CelebrationType; + speed?: number; + colors?: string[]; + intensity?: number; + duration?: number; + trigger?: "mount" | "hover" | "manual" | "click"; + active?: boolean; // For manual trigger + children?: React.ReactNode; +} + +interface ConfettiPiece { + x: number; + y: number; + width: number; + height: number; + color: string; + rotation: number; + rotationSpeed: number; + velocityX: number; + velocityY: number; + gravity: number; + opacity: number; + shape: "rectangle" | "circle" | "triangle"; +} + +interface Firework { + x: number; + y: number; + targetY: number; + velocityY: number; + color: string; + exploded: boolean; + particles: FireworkParticle[]; +} + +interface FireworkParticle { + x: number; + y: number; + velocityX: number; + velocityY: number; + color: string; + opacity: number; + size: number; + life: number; + maxLife: number; +} + +const CelebrationFx = React.forwardRef( + ( + { + type = "confetti", + speed = 1, + colors = ["brand-solid-medium", "accent-solid-medium"], + intensity = 50, + duration, + trigger = "mount", + active = true, + children, + ...rest + }, + forwardedRef, + ) => { + const containerRef = useRef(null); + const canvasRef = useRef(null); + const animationRef = useRef(undefined); + const particlesRef = useRef<(ConfettiPiece | Firework)[]>([]); + const isEmittingRef = useRef(trigger === "mount"); + const emitStartTimeRef = useRef(Date.now()); + const isHoveredRef = useRef(false); + const fireworkTimerRef = useRef(0); + const clickPositionRef = useRef<{ x: number; y: number } | null>(null); + + useEffect(() => { + if (forwardedRef) { + if ("current" in forwardedRef) { + forwardedRef.current = containerRef.current; + } else if (typeof forwardedRef === "function") { + forwardedRef(containerRef.current); + } + } + }, [forwardedRef]); + + // Handle manual trigger + useEffect(() => { + if (trigger === "manual") { + if (active) { + isEmittingRef.current = true; + emitStartTimeRef.current = Date.now(); + } else { + isEmittingRef.current = false; + } + } + }, [trigger, active]); + + useEffect(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // Set canvas size + let canvasWidth = 0; + let canvasHeight = 0; + + const updateSize = () => { + const rect = container.getBoundingClientRect(); + canvasWidth = rect.width; + canvasHeight = rect.height; + canvas.width = rect.width * 2; // 2x for retina + canvas.height = rect.height * 2; + canvas.style.width = `${rect.width}px`; + canvas.style.height = `${rect.height}px`; + ctx.scale(2, 2); // Scale for retina + }; + + updateSize(); + window.addEventListener("resize", updateSize); + + // Parse colors - convert token names to CSS variables + const parsedColors = colors.map((color) => { + const computedColor = getComputedStyle(container).getPropertyValue(`--${color}`); + return computedColor || color; + }); + + // Initialize confetti + const initializeConfetti = () => { + const particles: ConfettiPiece[] = []; + const shapes: ("rectangle" | "circle" | "triangle")[] = ["rectangle", "circle", "triangle"]; + + for (let i = 0; i < intensity; i++) { + // Stagger particles more dramatically over a larger vertical range + const stagger = (i / intensity) * canvasHeight * 2; + particles.push({ + x: Math.random() * canvasWidth, + y: -canvasHeight - Math.random() * canvasHeight - stagger, + width: 8 + Math.random() * 8, + height: 6 + Math.random() * 6, + color: parsedColors[Math.floor(Math.random() * parsedColors.length)], + rotation: Math.random() * Math.PI * 2, + rotationSpeed: (Math.random() - 0.5) * 0.2, + velocityX: (Math.random() - 0.5) * 3, + velocityY: (0.5 + Math.random() * 2.5) * speed, + gravity: (0.12 + Math.random() * 0.06) * speed, + opacity: 0.8 + Math.random() * 0.2, + shape: shapes[Math.floor(Math.random() * shapes.length)], + }); + } + + return particles; + }; + + // Create a new firework (explodes immediately at random or click position) + const createFirework = (clickPos?: { x: number; y: number }) => { + const firework: Firework = { + x: clickPos ? clickPos.x : Math.random() * canvasWidth, + y: clickPos ? clickPos.y : canvasHeight * (0.2 + Math.random() * 0.4), + targetY: 0, // Not used anymore + velocityY: 0, + color: parsedColors[Math.floor(Math.random() * parsedColors.length)], + exploded: true, // Start already exploded + particles: [], + }; + // Explode immediately + explodeFirework(firework); + return firework; + }; + + // Explode firework into particles + const explodeFirework = (firework: Firework) => { + const particleCount = 30 + Math.floor(Math.random() * 20); + for (let i = 0; i < particleCount; i++) { + const angle = (Math.PI * 2 * i) / particleCount; + const velocity = 1 + Math.random() * 3; + firework.particles.push({ + x: firework.x, + y: firework.y, + velocityX: Math.cos(angle) * velocity * speed, + velocityY: Math.sin(angle) * velocity * speed, + color: firework.color, + opacity: 1, + size: 1 + Math.random(), + life: 0, + maxLife: 60 + Math.random() * 40, + }); + } + firework.exploded = true; + }; + + // Initialize particles only if not already initialized + if (particlesRef.current.length === 0 && type === "confetti") { + particlesRef.current = initializeConfetti(); + } + + // Animation loop + const animate = () => { + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + + // Check if we should still be emitting new particles + const shouldEmit = + trigger === "hover" ? isHoveredRef.current : + trigger === "manual" ? active : + trigger === "click" ? clickPositionRef.current !== null : + isEmittingRef.current; + + // Check duration limit + if (duration && isEmittingRef.current && trigger !== "manual") { + const elapsed = (Date.now() - emitStartTimeRef.current) / 1000; + if (elapsed > duration) { + isEmittingRef.current = false; + } + } + + if (type === "confetti") { + // Update and draw confetti + (particlesRef.current as ConfettiPiece[]).forEach((piece) => { + // Update physics + piece.velocityY += piece.gravity; + piece.x += piece.velocityX; + piece.y += piece.velocityY; + piece.rotation += piece.rotationSpeed; + + // Add some air resistance + piece.velocityX *= 0.99; + + // Only respawn if we're still emitting + if (piece.y > canvasHeight + 50 && shouldEmit) { + piece.y = -20 - Math.random() * canvasHeight * 0.5; + piece.x = Math.random() * canvasWidth; + piece.velocityX = (Math.random() - 0.5) * 3; + piece.velocityY = (0.5 + Math.random() * 2.5) * speed; + piece.gravity = (0.12 + Math.random() * 0.06) * speed; + } + + // Only draw if still on screen + if (piece.y < canvasHeight + 100) { + // Draw confetti piece + ctx.save(); + ctx.translate(piece.x, piece.y); + ctx.rotate(piece.rotation); + ctx.globalAlpha = piece.opacity; + ctx.fillStyle = piece.color; + + if (piece.shape === "rectangle") { + ctx.fillRect(-piece.width / 2, -piece.height / 2, piece.width, piece.height); + } else if (piece.shape === "circle") { + ctx.beginPath(); + ctx.arc(0, 0, piece.width / 2, 0, Math.PI * 2); + ctx.fill(); + } else if (piece.shape === "triangle") { + ctx.beginPath(); + ctx.moveTo(0, -piece.height / 2); + ctx.lineTo(piece.width / 2, piece.height / 2); + ctx.lineTo(-piece.width / 2, piece.height / 2); + ctx.closePath(); + ctx.fill(); + } + + ctx.restore(); + } + }); + } else if (type === "fireworks") { + // Launch new fireworks + if (trigger === "click" && clickPositionRef.current) { + // Fire from click position + particlesRef.current.push(createFirework(clickPositionRef.current)); + clickPositionRef.current = null; + } else { + fireworkTimerRef.current++; + if (shouldEmit && fireworkTimerRef.current > 20 / speed) { + // Launch new firework every ~20 frames + fireworkTimerRef.current = 0; + particlesRef.current.push(createFirework()); + } + } + + // Update and draw fireworks (explosion particles only) + (particlesRef.current as Firework[]).forEach((firework) => { + // Update and draw explosion particles + firework.particles.forEach((particle) => { + particle.life++; + + if (particle.life < particle.maxLife) { + // Update position + particle.x += particle.velocityX; + particle.y += particle.velocityY; + particle.velocityY += 0.1; // Gravity + + // Fade out + particle.opacity = 1 - particle.life / particle.maxLife; + + // Draw particle + ctx.globalAlpha = particle.opacity; + ctx.fillStyle = particle.color; + ctx.beginPath(); + ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); + ctx.fill(); + } + }); + }); + + // Clean up dead fireworks (only when not emitting) + if (!shouldEmit) { + particlesRef.current = (particlesRef.current as Firework[]).filter((firework) => { + // Keep if any particles are still alive + return firework.particles.some(p => p.life < p.maxLife); + }); + } + } + + ctx.globalAlpha = 1; + animationRef.current = requestAnimationFrame(animate); + }; + + animate(); + + return () => { + window.removeEventListener("resize", updateSize); + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + }; + }, [type, colors, speed, intensity, duration, trigger]); + + const handleMouseEnter = () => { + if (trigger === "hover" && !isHoveredRef.current) { + isHoveredRef.current = true; + isEmittingRef.current = true; + emitStartTimeRef.current = Date.now(); + } + }; + + const handleMouseLeave = () => { + if (trigger === "hover" && isHoveredRef.current) { + isHoveredRef.current = false; + } + }; + + const handleClick = (e: React.MouseEvent) => { + if (trigger === "click" && containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + if (type === "confetti") { + // For confetti, add burst of particles from click position + const shapes: ("rectangle" | "circle" | "triangle")[] = ["rectangle", "circle", "triangle"]; + const canvas = canvasRef.current; + if (!canvas) return; + + const parsedColors = colors.map((color) => { + const computedColor = getComputedStyle(containerRef.current!).getPropertyValue(`--${color}`); + return computedColor || color; + }); + + for (let i = 0; i < intensity; i++) { + const angle = (Math.PI * 2 * i) / intensity; + const velocity = 2 + Math.random() * 3; + (particlesRef.current as ConfettiPiece[]).push({ + x, + y, + width: 8 + Math.random() * 8, + height: 6 + Math.random() * 6, + color: parsedColors[Math.floor(Math.random() * parsedColors.length)], + rotation: Math.random() * Math.PI * 2, + rotationSpeed: (Math.random() - 0.5) * 0.2, + velocityX: Math.cos(angle) * velocity, + velocityY: Math.sin(angle) * velocity - 2, // Slight upward bias + gravity: (0.12 + Math.random() * 0.06) * speed, + opacity: 0.8 + Math.random() * 0.2, + shape: shapes[Math.floor(Math.random() * shapes.length)], + }); + } + } else { + // For fireworks, store click position + clickPositionRef.current = { x, y }; + } + } + }; + + return ( + + + {children} + + ); + }, +); + +CelebrationFx.displayName = "CelebrationFx"; +export { CelebrationFx }; diff --git a/packages/core/src/components/MatrixFx.tsx b/packages/core/src/components/MatrixFx.tsx index 5c484f6..e6a7389 100644 --- a/packages/core/src/components/MatrixFx.tsx +++ b/packages/core/src/components/MatrixFx.tsx @@ -11,13 +11,30 @@ interface BulgeConfig { delay?: number; } +interface Dot { + x: number; + y: number; + gridX: number; + gridY: number; + color: string; + baseOpacity: number; + distanceFromOrigin: number; + randomOffset: number; + flickerPhase: number; + flickerSpeed: number; + gridSize?: number; + canvasW?: number; + canvasH?: number; +} + interface MatrixFxProps extends React.ComponentProps { speed?: number; colors?: string[]; size?: number; spacing?: number; revealFrom?: "center" | "top" | "bottom" | "left" | "right"; - trigger?: "hover" | "instant" | "mount"; + trigger?: "hover" | "instant" | "mount" | "click" | "manual"; + active?: boolean; flicker?: boolean; bulge?: BulgeConfig; children?: React.ReactNode; @@ -32,6 +49,7 @@ const MatrixFx = React.forwardRef( spacing = 3, revealFrom = "center", trigger = "instant", + active = false, flicker = false, bulge, children, @@ -49,6 +67,7 @@ const MatrixFx = React.forwardRef( const isHoveredRef = useRef(false); const mountAnimationCompleteRef = useRef(false); const bulgeStartTimeRef = useRef(Date.now()); + const dotsRef = useRef([]); useEffect(() => { if (forwardedRef) { @@ -101,68 +120,72 @@ const MatrixFx = React.forwardRef( const cols = Math.ceil(paddedWidth / totalSize); const rows = Math.ceil(paddedHeight / totalSize); - interface Dot { - x: number; - y: number; - gridX: number; - gridY: number; - color: string; - baseOpacity: number; - distanceFromOrigin: number; - randomOffset: number; - flickerPhase: number; - flickerSpeed: number; - } + // Only create new dots if grid doesn't exist or dimensions/size changed + let dots: Dot[] = dotsRef.current; + let maxDistance = 0; + + if (dots.length === 0 || dots[0]?.gridSize !== totalSize || dots[0]?.canvasW !== canvasWidth || dots[0]?.canvasH !== canvasHeight) { + // Create new dot grid + dots = []; + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const x = col * totalSize + size / 2 - maxDisplacement; + const y = row * totalSize + size / 2 - maxDisplacement; + + // Calculate distance from reveal origin + let distanceFromOrigin = 0; + const centerX = canvasWidth / 2; + const centerY = canvasHeight / 2; + + switch (revealFrom) { + case "center": + const dx = x - centerX; + const dy = y - centerY; + distanceFromOrigin = Math.sqrt(dx * dx + dy * dy); + break; + case "top": + distanceFromOrigin = y; + break; + case "bottom": + distanceFromOrigin = canvasHeight - y; + break; + case "left": + distanceFromOrigin = x; + break; + case "right": + distanceFromOrigin = canvasWidth - x; + break; + } - const dots: Dot[] = []; - - for (let row = 0; row < rows; row++) { - for (let col = 0; col < cols; col++) { - const x = col * totalSize + size / 2 - maxDisplacement; - const y = row * totalSize + size / 2 - maxDisplacement; - - // Calculate distance from reveal origin - let distanceFromOrigin = 0; - const centerX = canvasWidth / 2; - const centerY = canvasHeight / 2; - - switch (revealFrom) { - case "center": - const dx = x - centerX; - const dy = y - centerY; - distanceFromOrigin = Math.sqrt(dx * dx + dy * dy); - break; - case "top": - distanceFromOrigin = y; - break; - case "bottom": - distanceFromOrigin = canvasHeight - y; - break; - case "left": - distanceFromOrigin = x; - break; - case "right": - distanceFromOrigin = canvasWidth - x; - break; + dots.push({ + x, + y, + gridX: col, + gridY: row, + color: parsedColors[Math.floor(Math.random() * parsedColors.length)], + baseOpacity: 0.3 + Math.random() * 0.7, + distanceFromOrigin, + randomOffset: Math.random() * 0.3, + flickerPhase: Math.random() * Math.PI * 2, + flickerSpeed: 0.8 + Math.random() * 0.4, + gridSize: totalSize, + canvasW: canvasWidth, + canvasH: canvasHeight, + }); } - - dots.push({ - x, - y, - gridX: col, - gridY: row, - color: parsedColors[Math.floor(Math.random() * parsedColors.length)], - baseOpacity: 0.3 + Math.random() * 0.7, - distanceFromOrigin, - randomOffset: Math.random() * 0.3, - flickerPhase: Math.random() * Math.PI * 2, // Random starting point - flickerSpeed: 0.8 + Math.random() * 0.4, // Random speed between 0.8 and 1.2 - }); } - } - // Find max distance for normalization - const maxDistance = Math.max(...dots.map((d) => d.distanceFromOrigin)); + // Find max distance for normalization + maxDistance = Math.max(...dots.map((d) => d.distanceFromOrigin)); + dotsRef.current = dots; + } else { + // Update colors on existing dots + dots.forEach((dot) => { + dot.color = parsedColors[Math.floor(Math.random() * parsedColors.length)]; + }); + maxDistance = Math.max(...dots.map((d) => d.distanceFromOrigin)); + } // Animation loop @@ -475,9 +498,10 @@ const MatrixFx = React.forwardRef( return; } - // For hover trigger with animation - if (isHoveredRef.current) { - // Revealing animation with explosive easing + // For hover, click, and manual triggers with animation + if (trigger === "hover" || trigger === "click" || trigger === "manual") { + if (isHoveredRef.current) { + // Revealing animation with explosive easing const now = Date.now(); const elapsed = (now - revealStartTimeRef.current) / 1000; // Cubic easing: starts very slow, then explodes @@ -622,6 +646,7 @@ const MatrixFx = React.forwardRef( hideStartProgressRef.current = 0; } } + } } ctx.globalAlpha = 1; @@ -638,6 +663,39 @@ const MatrixFx = React.forwardRef( }; }, [colors, size, spacing, speed, revealFrom, trigger, flicker, bulge]); + // Manual trigger control via `active` prop + useEffect(() => { + if (trigger !== "manual") return; + const now = Date.now(); + if (active) { + // Mimic mouse enter + if (hideStartProgressRef.current > 0) { + const hideElapsed = (now - hideStartTimeRef.current) / 1000; + const hideSpeed = speed * 6; + const hideProgress = Math.pow(hideElapsed, 2) * hideSpeed; + const currentProgress = Math.max(0, hideStartProgressRef.current - hideProgress); + const effectiveElapsed = Math.pow(currentProgress / (speed * 3), 1 / 3); + const simulatedStartTime = now - effectiveElapsed * 1000; + revealStartTimeRef.current = simulatedStartTime; + } else { + revealStartTimeRef.current = now; + } + if (bulge && !bulge.repeat) { + bulgeStartTimeRef.current = now; + } + isHoveredRef.current = true; + hideStartProgressRef.current = 0; + } else { + // Mimic mouse leave + if (isHoveredRef.current) { + const currentProgress = maxRevealProgressRef.current; + hideStartTimeRef.current = Date.now(); + hideStartProgressRef.current = currentProgress; + isHoveredRef.current = false; + } + } + }, [active, trigger, speed, bulge]); + const handleMouseEnter = () => { if (trigger === "hover" && !isHoveredRef.current) { const now = Date.now(); @@ -686,6 +744,36 @@ const MatrixFx = React.forwardRef( } }; + const handleClick = () => { + if (trigger !== "click") return; + if (!isHoveredRef.current) { + // Enter + const now = Date.now(); + if (hideStartProgressRef.current > 0) { + const hideElapsed = (now - hideStartTimeRef.current) / 1000; + const hideSpeed = speed * 6; + const hideProgress = Math.pow(hideElapsed, 2) * hideSpeed; + const currentProgress = Math.max(0, hideStartProgressRef.current - hideProgress); + const effectiveElapsed = Math.pow(currentProgress / (speed * 3), 1 / 3); + const simulatedStartTime = now - effectiveElapsed * 1000; + revealStartTimeRef.current = simulatedStartTime; + } else { + revealStartTimeRef.current = now; + } + if (bulge && !bulge.repeat) { + bulgeStartTimeRef.current = now; + } + isHoveredRef.current = true; + hideStartProgressRef.current = 0; + } else { + // Leave + const currentProgress = maxRevealProgressRef.current; + hideStartTimeRef.current = Date.now(); + hideStartProgressRef.current = currentProgress; + isHoveredRef.current = false; + } + }; + return ( ( onMouseEnter={handleMouseEnter} onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} + onClick={handleClick} {...rest} > = ({ const imageRef = useRef(null); const handleImageClick = () => { - if (enlarge && !isEnlarged) { - setIsEnlarged(true); + if (enlarge) { + if (!isEnlarged) { + setIsEnlarged(true); + } else { + setIsEnlarged(false); + } } }; @@ -159,7 +163,6 @@ const Media: React.FC = ({ overflow="hidden" zIndex={0} margin="0" - cursor={enlarge ? "interactive" : undefined} style={{ outline: "none", isolation: "isolate", @@ -170,7 +173,7 @@ const Media: React.FC = ({ ...style, }} onClick={handleImageClick} - className={classNames(enlarge && !isEnlarged ? "cursor-interactive" : undefined, className)} + className={classNames(enlarge && !isEnlarged ? "cursor-zoom-in" : enlarge && isEnlarged ? "cursor-zoom-out" : undefined, className)} {...rest} > {loading && } @@ -205,7 +208,7 @@ const Media: React.FC = ({ {alt} = ({ defaultSelected, fillWidth = true, selected, + compact = false, className, style, ...scrollerProps @@ -103,21 +105,27 @@ const SegmentedControl: React.FC = ({ aria-orientation="horizontal" onKeyDown={handleKeyDown} > - + {buttons.map((button, index) => { return ( { buttonRefs.current[index] = el as HTMLButtonElement; }} - variant="outline" - radius={index === 0 ? "left" : index === buttons.length - 1 ? "right" : "none"} + variant={compact ? "outline" : "ghost"} + radius={compact ? (index === 0 ? "left" : index === buttons.length - 1 ? "right" : "none") : undefined} key={button.value} selected={index === selectedIndex} onClick={(event) => handleButtonClick(button, event)} role="tab" className={className} - style={style} + style={{opacity: (index !== selectedIndex && !compact) ? 0.6 : 1, ...style}} aria-selected={index === selectedIndex} aria-controls={`panel-${button.value}`} tabIndex={index === selectedIndex ? 0 : -1} diff --git a/packages/core/src/components/ShineFx.module.scss b/packages/core/src/components/ShineFx.module.scss index 12d1acd..8083cee 100644 --- a/packages/core/src/components/ShineFx.module.scss +++ b/packages/core/src/components/ShineFx.module.scss @@ -15,6 +15,23 @@ animation: shine 5s linear infinite; } +.inverse { + --shine-base-opacity: 0.3; + + display: inline-block; + -webkit-text-fill-color: transparent; + background: linear-gradient( + 120deg, + currentColor 40%, + color-mix(in srgb, currentColor, transparent calc((1 - var(--shine-base-opacity)) * 100%)) 50%, + currentColor 60% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + animation: shine 5s linear infinite; +} + @keyframes shine { 0% { background-position: 100%; @@ -30,4 +47,4 @@ background: none; -webkit-background-clip: unset; background-clip: unset; -} +} \ No newline at end of file diff --git a/packages/core/src/components/ShineFx.tsx b/packages/core/src/components/ShineFx.tsx index 944e06a..2082a7a 100644 --- a/packages/core/src/components/ShineFx.tsx +++ b/packages/core/src/components/ShineFx.tsx @@ -3,10 +3,12 @@ import React from "react"; import { Text } from "."; import styles from "./ShineFx.module.scss"; +import classNames from "classnames"; export interface ShineFxProps extends React.ComponentProps { speed?: number; disabled?: boolean; + inverse?: boolean; baseOpacity?: number; children?: React.ReactNode; } @@ -14,6 +16,7 @@ export interface ShineFxProps extends React.ComponentProps { const ShineFx: React.FC = ({ speed = 1, disabled = false, + inverse = false, baseOpacity = 0.3, children, className, @@ -25,7 +28,7 @@ const ShineFx: React.FC = ({ return ( { className?: string; } -const shapes = ["conservative", "playful", "rounded"]; +const shapes = ["sharp", "conservative", "playful", "rounded"]; const colorOptions = { brand: [...schemes], diff --git a/packages/core/src/components/WeatherFx.tsx b/packages/core/src/components/WeatherFx.tsx index fff5c20..f92afd0 100644 --- a/packages/core/src/components/WeatherFx.tsx +++ b/packages/core/src/components/WeatherFx.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useRef } from "react"; import { Flex } from "."; -type WeatherType = "rain" | "snow" | "leaves"; +type WeatherType = "rain" | "snow" | "leaves" | "lightning"; interface WeatherFxProps extends React.ComponentProps { type?: WeatherType; @@ -12,7 +12,8 @@ interface WeatherFxProps extends React.ComponentProps { intensity?: number; angle?: number; duration?: number; - trigger?: "mount" | "hover"; + trigger?: "mount" | "hover" | "click" | "manual"; + active?: boolean; children?: React.ReactNode; } @@ -58,6 +59,26 @@ interface Leaf { depth: number; } +interface LightningBranch { + startIndex: number; + segments: { x: number; y: number }[]; + thickness: number; + children: LightningBranch[]; // Hierarchical sub-branches +} + +interface Lightning { + x: number; + y: number; + segments: { x: number; y: number }[]; + color: string; + opacity: number; + thickness: number; + lifetime: number; + age: number; + branches: LightningBranch[]; + revealDuration: number; // Duration for the reveal animation +} + const WeatherFx = React.forwardRef( ( { @@ -68,6 +89,7 @@ const WeatherFx = React.forwardRef( angle = 0, duration, trigger = "mount", + active = false, children, ...rest }, @@ -76,11 +98,13 @@ const WeatherFx = React.forwardRef( const containerRef = useRef(null); const canvasRef = useRef(null); const animationRef = useRef(undefined); - const particlesRef = useRef<(RainDrop | Snowflake | Leaf)[]>([]); + const particlesRef = useRef<(RainDrop | Snowflake | Leaf | Lightning)[]>([]); const timeRef = useRef(0); - const isEmittingRef = useRef(trigger === "mount"); + const isEmittingRef = useRef(trigger === "mount" || (trigger === "manual" && active)); const emitStartTimeRef = useRef(Date.now()); const isHoveredRef = useRef(false); + const lastLightningTimeRef = useRef(0); + const lastBoltCountRef = useRef(0); useEffect(() => { if (forwardedRef) { @@ -230,8 +254,169 @@ const WeatherFx = React.forwardRef( return particles; }; - // Initialize particles - particlesRef.current = type === "rain" ? initializeRain() : type === "snow" ? initializeSnow() : type === "leaves" ? initializeLeaves() : []; + // Recursive function to generate chaotic, organic branches + const generateBranch = ( + startX: number, + startY: number, + baseSegmentLength: number, + branchLevel: number, + maxLevel: number, + angle: number, + parentThickness: number + ): { segments: { x: number; y: number }[]; children: LightningBranch[] } => { + const segments: { x: number; y: number }[] = []; + const children: LightningBranch[] = []; + + // Longer branches that decrease more gradually with depth + const lengthMultiplier = Math.pow(0.7, branchLevel); + const branchLength = 3 + Math.floor(Math.random() * 4); // 3-6 segments + + let currentX = startX; + let currentY = startY; + let currentAngle = angle; + + for (let i = 0; i < branchLength; i++) { + // Larger segments for longer branches + const segmentLength = baseSegmentLength * lengthMultiplier * (0.6 + Math.random() * 0.6); + + // Add angular variation for organic chaos + currentAngle += (Math.random() - 0.5) * 0.8; + + // Move in the current angle direction + currentX += Math.cos(currentAngle) * segmentLength; + currentY += Math.sin(currentAngle) * segmentLength; + + segments.push({ x: currentX, y: currentY }); + + // Moderate probability of branching + if (branchLevel < maxLevel && i > 0) { + // Only primary branches get multiple attempts + const branchAttempts = branchLevel === 1 ? 1 : 1; + + for (let attempt = 0; attempt < branchAttempts; attempt++) { + // Lower probability: 35% at level 1, 25% at level 2, etc. + const branchProbability = 0.35 - branchLevel * 0.1; + + if (Math.random() < branchProbability) { + // Random angle deviation from current direction (-90 to +90 degrees) + const angleDeviation = (Math.random() - 0.5) * Math.PI; + const childAngle = currentAngle + angleDeviation; + + const childBranch = generateBranch( + currentX, + currentY, + baseSegmentLength, + branchLevel + 1, + maxLevel, + childAngle, + parentThickness + ); + + if (childBranch.segments.length > 0) { + children.push({ + startIndex: i, + segments: childBranch.segments, + thickness: parentThickness * Math.pow(0.6, branchLevel + 1), + children: childBranch.children, + }); + } + } + } + } + } + + return { segments, children }; + }; + + // Generate lightning bolt with chaotic branches + const generateLightningBolt = (startX: number, startY: number, endY: number): Lightning => { + const segments: { x: number; y: number }[] = []; + const branches: LightningBranch[] = []; + + let currentX = startX; + let currentY = startY; + const baseSegmentLength = 12 + Math.random() * 15; // Smaller segments for detail + const mainThickness = 2.5 + Math.random() * 1.5; + + segments.push({ x: currentX, y: currentY }); + + // Main bolt with more erratic path + let currentAngle = Math.PI / 2; // Start going down (90 degrees) + + while (currentY < endY) { + // Vary segment length significantly + const segmentLength = baseSegmentLength * (0.5 + Math.random() * 0.8); + + // Add significant angular variation for jagged appearance + currentAngle += (Math.random() - 0.5) * 0.6; + + // Ensure we're generally moving downward + if (currentAngle < Math.PI / 4) currentAngle = Math.PI / 4; // Min 45 degrees + if (currentAngle > (3 * Math.PI) / 4) currentAngle = (3 * Math.PI) / 4; // Max 135 degrees + + currentX += Math.cos(currentAngle) * segmentLength; + currentY += Math.sin(currentAngle) * segmentLength; + + segments.push({ x: currentX, y: currentY }); + + // Moderate chance of branching (35%) for balanced appearance + if (Math.random() < 0.35 && segments.length > 2) { + // Random angle deviation (-120 to +120 degrees from current) + const angleDeviation = (Math.random() - 0.5) * (2 * Math.PI / 3); + const branchAngle = currentAngle + angleDeviation; + + const primaryBranch = generateBranch( + currentX, + currentY, + baseSegmentLength, + 1, + 3, // Max 3 levels to reduce chaos + branchAngle, + mainThickness + ); + + if (primaryBranch.segments.length > 0) { + branches.push({ + startIndex: segments.length - 1, + segments: primaryBranch.segments, + thickness: mainThickness * 0.6, + children: primaryBranch.children, + }); + } + } + } + + return { + x: startX, + y: startY, + segments, + color: parsedColors[Math.floor(Math.random() * parsedColors.length)], + opacity: 0.8 + Math.random() * 0.2, + thickness: mainThickness, + lifetime: 0.4 + Math.random() * 0.2, + age: 0, + branches, + revealDuration: 0.08 + Math.random() * 0.04, + }; + }; + + // Initialize lightning (empty array, lightning spawns dynamically) + const initializeLightning = () => { + return [] as Lightning[]; + }; + + // Initialize particles only for mount trigger (manual/click handled by their respective handlers) + // Only initialize if particles don't already exist to preserve active particles across re-renders + if (particlesRef.current.length === 0) { + const shouldInitialize = trigger === "mount"; + particlesRef.current = shouldInitialize + ? (type === "rain" ? initializeRain() : + type === "snow" ? initializeSnow() : + type === "leaves" ? initializeLeaves() : + type === "lightning" ? initializeLightning() : + []) + : []; + } // Animation loop const angleRad = (angle * Math.PI) / 180; // Convert angle to radians @@ -400,6 +585,152 @@ const WeatherFx = React.forwardRef( ctx.restore(); }); + } else if (type === "lightning") { + // Lightning spawning logic + const currentTime = Date.now(); + const timeSinceLastLightning = (currentTime - lastLightningTimeRef.current) / 1000; + + // Reduced frequency: intensity 1 = ~15s, intensity 10 = ~3s, intensity 50 = ~0.6s, intensity 100 = ~0.3s + const spawnInterval = Math.max(0.3, 15 / intensity); + + if (shouldEmit && timeSinceLastLightning > spawnInterval) { + // Calculate number of simultaneous bolts based on intensity + // After a multi-bolt strike, force a single bolt for variation + let boltCount = 1; + + if (lastBoltCountRef.current > 1) { + // Previous strike was multi-bolt, do single bolt this time + boltCount = 1; + } else { + // Previous was single, can do multi-bolt based on intensity + if (intensity > 60) { + // 60% chance of multi-bolt at high intensity + boltCount = Math.random() < 0.6 ? (2 + Math.floor(Math.random() * 3)) : 1; // 2-4 bolts or 1 + } else if (intensity > 30) { + // 50% chance of multi-bolt at medium intensity + boltCount = Math.random() < 0.5 ? (2 + Math.floor(Math.random() * 2)) : 1; // 2-3 bolts or 1 + } else if (intensity > 15) { + // 30% chance of double bolt at low-medium intensity + boltCount = Math.random() < 0.3 ? 2 : 1; + } + // intensity <= 15: always single bolt + } + + // Store bolt count for next iteration + lastBoltCountRef.current = boltCount; + + // Spawn multiple bolts with slight time offset for realism + for (let i = 0; i < boltCount; i++) { + const startX = Math.random() * canvasWidth; + const bolt = generateLightningBolt(startX, 0, canvasHeight); + // Add slight age offset for staggered appearance + bolt.age = -i * 0.025; // 25ms offset between bolts + (particlesRef.current as Lightning[]).push(bolt); + } + + lastLightningTimeRef.current = currentTime; + } + + // Update and draw lightning bolts + (particlesRef.current as Lightning[]).forEach((bolt, index) => { + bolt.age += 0.016; // Increment age + + // Skip if not yet started (negative age for staggered bolts) + if (bolt.age < 0) return; + + // Remove expired bolts + if (bolt.age > bolt.lifetime) { + (particlesRef.current as Lightning[]).splice(index, 1); + return; + } + + // Calculate reveal progress (how much of the bolt to draw) + const revealProgress = Math.min(1, bolt.age / bolt.revealDuration); + + // Calculate opacity fade (flash effect) + const lifeProgress = bolt.age / bolt.lifetime; + let currentOpacity = bolt.opacity; + + // During reveal: full brightness + // After reveal: hold briefly, then fade out + if (lifeProgress < bolt.revealDuration / bolt.lifetime) { + currentOpacity *= revealProgress; // Fade in during reveal + } else if (lifeProgress < 0.3) { + currentOpacity *= 1; // Hold at full brightness + } else { + // Fade out in the remaining time + const fadeProgress = (lifeProgress - 0.3) / 0.7; + currentOpacity *= (1 - fadeProgress); + } + + // Calculate how many segments to draw based on reveal progress + const totalSegments = bolt.segments.length; + const segmentsToDraw = Math.ceil(totalSegments * revealProgress); + + // Draw main bolt with reveal effect + ctx.globalAlpha = currentOpacity; + ctx.strokeStyle = bolt.color; + ctx.lineWidth = bolt.thickness; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + // Draw glow effect + ctx.shadowBlur = 15; + ctx.shadowColor = bolt.color; + + ctx.beginPath(); + for (let i = 0; i < segmentsToDraw; i++) { + const segment = bolt.segments[i]; + if (i === 0) { + ctx.moveTo(segment.x, segment.y); + } else { + ctx.lineTo(segment.x, segment.y); + } + } + ctx.stroke(); + + // Recursive function to draw hierarchical branches + const drawBranch = ( + branch: LightningBranch, + parentSegments: { x: number; y: number }[], + parentSegmentsToDraw: number + ) => { + // Only draw if parent bolt has reached this branch point + if (branch.startIndex < parentSegmentsToDraw) { + const startSegment = parentSegments[branch.startIndex]; + + // Calculate how much of this branch to reveal + const segmentsPastBranch = parentSegmentsToDraw - branch.startIndex; + const branchRevealProgress = Math.min(1, segmentsPastBranch / 3); + const branchSegmentsToDraw = Math.ceil(branch.segments.length * branchRevealProgress); + + // Draw this branch + ctx.beginPath(); + ctx.moveTo(startSegment.x, startSegment.y); + for (let i = 0; i < branchSegmentsToDraw; i++) { + const segment = branch.segments[i]; + ctx.lineTo(segment.x, segment.y); + } + ctx.lineWidth = branch.thickness; + ctx.stroke(); + + // Recursively draw child branches + if (branch.children && branch.children.length > 0) { + branch.children.forEach((childBranch) => { + drawBranch(childBranch, branch.segments, branchSegmentsToDraw); + }); + } + } + }; + + // Draw all primary branches and their hierarchies + bolt.branches.forEach((branch) => { + drawBranch(branch, bolt.segments, segmentsToDraw); + }); + + // Reset shadow + ctx.shadowBlur = 0; + }); } ctx.globalAlpha = 1; @@ -416,6 +747,106 @@ const WeatherFx = React.forwardRef( }; }, [type, colors, speed, intensity, angle, duration, trigger]); + // Respond to external control in manual mode + useEffect(() => { + if (trigger !== "manual") return; + if (active && !isEmittingRef.current) { + // Initialize particles if starting emission + if (particlesRef.current.length === 0) { + const canvas = canvasRef.current; + const container = containerRef.current; + if (canvas && container) { + const canvasWidth = canvas.width / 2; + const canvasHeight = canvas.height / 2; + const parsedColors = colors.map((color) => { + const computedColor = getComputedStyle(container).getPropertyValue(`--${color}`); + return computedColor || color; + }); + + if (type === "rain") { + const particles: RainDrop[] = []; + const angleRad = (angle * Math.PI) / 180; + const horizontalOffset = Math.abs(Math.tan(angleRad) * canvasHeight); + for (let i = 0; i < intensity; i++) { + const spawnWidth = canvasWidth + horizontalOffset * 2; + const spawnX = Math.random() * spawnWidth - horizontalOffset; + particles.push({ + x: spawnX, + y: Math.random() * canvasHeight - canvasHeight, + length: 10 + Math.random() * 20, + speed: (2 + Math.random() * 3) * speed, + color: parsedColors[Math.floor(Math.random() * parsedColors.length)], + opacity: 0.3 + Math.random() * 0.5, + thickness: 1 + Math.random() * 1.5, + }); + } + particlesRef.current = particles; + } else if (type === "snow") { + const particles: Snowflake[] = []; + const angleRad = (angle * Math.PI) / 180; + const horizontalOffset = Math.abs(Math.tan(angleRad) * canvasHeight); + for (let i = 0; i < intensity; i++) { + const depth = Math.random(); + const size = 2 + depth * 4; + const spawnWidth = canvasWidth + horizontalOffset * 2; + const spawnX = Math.random() * spawnWidth - horizontalOffset; + particles.push({ + x: spawnX, + y: Math.random() * canvasHeight - canvasHeight, + size, + speed: (0.3 + depth * 0.7) * speed, + color: parsedColors[Math.floor(Math.random() * parsedColors.length)], + opacity: 0.4 + depth * 0.5, + swayAmplitude: 20 + Math.random() * 30, + swaySpeed: 0.5 + Math.random() * 1, + swayOffset: Math.random() * Math.PI * 2, + depth, + }); + } + particlesRef.current = particles; + } else if (type === "leaves") { + const particles: Leaf[] = []; + const angleRad = (angle * Math.PI) / 180; + const horizontalOffset = Math.abs(Math.tan(angleRad) * canvasHeight); + for (let i = 0; i < intensity; i++) { + const depth = Math.random(); + const width = 8 + depth * 12; + const height = width * (0.6 + Math.random() * 0.4); + const spawnWidth = canvasWidth + horizontalOffset * 2; + const spawnX = Math.random() * spawnWidth - horizontalOffset; + const colorIndex = Math.floor(Math.random() * parsedColors.length); + const color1 = parsedColors[colorIndex]; + const color2 = parsedColors[Math.min(colorIndex + 1, parsedColors.length - 1)]; + particles.push({ + x: spawnX, + y: Math.random() * canvasHeight - canvasHeight, + width, + height, + speed: (0.4 + depth * 0.8) * speed, + color1, + color2, + opacity: 0.6 + depth * 0.3, + swayAmplitude: 30 + Math.random() * 50, + swaySpeed: 0.3 + Math.random() * 0.7, + swayOffset: Math.random() * Math.PI * 2, + rotation: Math.random() * Math.PI * 2, + rotationSpeed: (Math.random() - 0.5) * 0.08, + rotation3D: Math.random() * Math.PI * 2, + rotation3DSpeed: (Math.random() - 0.5) * 0.06, + depth, + }); + } + particlesRef.current = particles; + } + } + } + isEmittingRef.current = true; + emitStartTimeRef.current = Date.now(); + } else if (!active && isEmittingRef.current) { + isEmittingRef.current = false; + } + }, [active, trigger]); + const handleMouseEnter = () => { if (trigger === "hover" && !isHoveredRef.current) { isHoveredRef.current = true; @@ -430,6 +861,105 @@ const WeatherFx = React.forwardRef( } }; + const handleClick = () => { + if (trigger !== "click") return; + if (!isEmittingRef.current) { + // Initialize particles if starting emission + if (particlesRef.current.length === 0) { + const canvas = canvasRef.current; + const container = containerRef.current; + if (canvas && container) { + const canvasWidth = canvas.width / 2; + const canvasHeight = canvas.height / 2; + const parsedColors = colors.map((color) => { + const computedColor = getComputedStyle(container).getPropertyValue(`--${color}`); + return computedColor || color; + }); + + if (type === "rain") { + const particles: RainDrop[] = []; + const angleRad = (angle * Math.PI) / 180; + const horizontalOffset = Math.abs(Math.tan(angleRad) * canvasHeight); + for (let i = 0; i < intensity; i++) { + const spawnWidth = canvasWidth + horizontalOffset * 2; + const spawnX = Math.random() * spawnWidth - horizontalOffset; + particles.push({ + x: spawnX, + y: Math.random() * canvasHeight - canvasHeight, + length: 10 + Math.random() * 20, + speed: (2 + Math.random() * 3) * speed, + color: parsedColors[Math.floor(Math.random() * parsedColors.length)], + opacity: 0.3 + Math.random() * 0.5, + thickness: 1 + Math.random() * 1.5, + }); + } + particlesRef.current = particles; + } else if (type === "snow") { + const particles: Snowflake[] = []; + const angleRad = (angle * Math.PI) / 180; + const horizontalOffset = Math.abs(Math.tan(angleRad) * canvasHeight); + for (let i = 0; i < intensity; i++) { + const depth = Math.random(); + const size = 2 + depth * 4; + const spawnWidth = canvasWidth + horizontalOffset * 2; + const spawnX = Math.random() * spawnWidth - horizontalOffset; + particles.push({ + x: spawnX, + y: Math.random() * canvasHeight - canvasHeight, + size, + speed: (0.3 + depth * 0.7) * speed, + color: parsedColors[Math.floor(Math.random() * parsedColors.length)], + opacity: 0.4 + depth * 0.5, + swayAmplitude: 20 + Math.random() * 30, + swaySpeed: 0.5 + Math.random() * 1, + swayOffset: Math.random() * Math.PI * 2, + depth, + }); + } + particlesRef.current = particles; + } else if (type === "leaves") { + const particles: Leaf[] = []; + const angleRad = (angle * Math.PI) / 180; + const horizontalOffset = Math.abs(Math.tan(angleRad) * canvasHeight); + for (let i = 0; i < intensity; i++) { + const depth = Math.random(); + const width = 8 + depth * 12; + const height = width * (0.6 + Math.random() * 0.4); + const spawnWidth = canvasWidth + horizontalOffset * 2; + const spawnX = Math.random() * spawnWidth - horizontalOffset; + const colorIndex = Math.floor(Math.random() * parsedColors.length); + const color1 = parsedColors[colorIndex]; + const color2 = parsedColors[Math.min(colorIndex + 1, parsedColors.length - 1)]; + particles.push({ + x: spawnX, + y: Math.random() * canvasHeight - canvasHeight, + width, + height, + speed: (0.4 + depth * 0.8) * speed, + color1, + color2, + opacity: 0.6 + depth * 0.3, + swayAmplitude: 30 + Math.random() * 50, + swaySpeed: 0.3 + Math.random() * 0.7, + swayOffset: Math.random() * Math.PI * 2, + rotation: Math.random() * Math.PI * 2, + rotationSpeed: (Math.random() - 0.5) * 0.08, + rotation3D: Math.random() * Math.PI * 2, + rotation3DSpeed: (Math.random() - 0.5) * 0.06, + depth, + }); + } + particlesRef.current = particles; + } + } + } + isEmittingRef.current = true; + emitStartTimeRef.current = Date.now(); + } else { + isEmittingRef.current = false; + } + }; + return ( ( overflow="hidden" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} + onClick={handleClick} {...rest} > = { chevronUp: HiChevronUp, @@ -97,6 +98,8 @@ export const iconLibrary: Record = { pause: HiOutlinePause, screen: HiOutlineComputerDesktop, document: HiOutlineBars3BottomLeft, + radialGauge: PiGauge, + linearGauge: PiBatteryFull }; export type IconLibrary = typeof iconLibrary; diff --git a/packages/core/src/modules/code/CodeBlock.tsx b/packages/core/src/modules/code/CodeBlock.tsx index ca2944c..cd9234e 100644 --- a/packages/core/src/modules/code/CodeBlock.tsx +++ b/packages/core/src/modules/code/CodeBlock.tsx @@ -660,7 +660,7 @@ const CodeBlock: React.FC = ({ = ({ = ({ = ({ variant: variantProp, barWidth = "l", hover = false, + reverseY = false, + reverseX = false, "data-viz-style": dataVizStyle, ...flex }) => { @@ -86,20 +90,27 @@ const BarChart: React.FC = ({ } }, [date?.start, date?.end]); - const handleDateRangeChange = (newRange: DateRange) => { - setSelectedDateRange(newRange); - if (date?.onChange) { - date.onChange(newRange); - } - }; + const handleDateRangeChange = useCallback( + (newRange: DateRange) => { + setSelectedDateRange(newRange); + if (date?.onChange) { + date.onChange(newRange); + } + }, + [date], + ); const seriesArray = Array.isArray(series) ? series : series ? [series] : []; const seriesKeys = seriesArray.map((s) => s.key); const chartId = React.useMemo(() => Math.random().toString(36).substring(2, 9), []); - const coloredSeriesArray = seriesArray.map((s, index) => ({ - ...s, - color: s.color || getDistributedColor(index, seriesArray.length), - })); + const coloredSeriesArray = useMemo( + () => + seriesArray.map((s, index) => ({ + ...s, + color: s.color || getDistributedColor(index, seriesArray.length), + })), + [seriesArray], + ); const xAxisKey = Object.keys(data[0] || {}).find((key) => !seriesKeys.includes(key)) || "name"; @@ -114,6 +125,63 @@ const BarChart: React.FC = ({ const barColors = autoSeries.map((s) => `var(--data-${s.color})`); + const legendContent = useCallback( + (props: any) => { + const customPayload = autoSeries.map((series, index) => ({ + value: series.key, + color: barColors[index], + })); + + return ( + + ); + }, + [autoSeries, barColors, variant, axis, legend.position, legend.direction], + ); + + const legendWrapperStyle = useMemo( + () => ({ + position: "absolute" as const, + top: + legend.position === "top-center" || + legend.position === "top-left" || + legend.position === "top-right" + ? reverseX ? 32 : 0 + : undefined, + bottom: + legend.position === "bottom-center" || + legend.position === "bottom-left" || + legend.position === "bottom-right" + ? 0 + : undefined, + paddingBottom: + legend.position === "bottom-center" || + legend.position === "bottom-left" || + legend.position === "bottom-right" + ? "var(--static-space-40)" + : undefined, + left: + (axis === "y" || axis === "both") && + (legend.position === "top-center" || legend.position === "bottom-center") + ? "var(--static-space-64)" + : 0, + width: + (axis === "y" || axis === "both") && + (legend.position === "top-center" || legend.position === "bottom-center") + ? "calc(100% - var(--static-space-64))" + : "100%", + right: 0, + margin: 0, + }), + [legend.position, reverseX, axis], + ); + const filteredData = React.useMemo(() => { if (!selectedDateRange || !data || data.length === 0) { return data; @@ -140,7 +208,7 @@ const BarChart: React.FC = ({ return ( = ({ {legend.display && ( { - const customPayload = autoSeries.map((series, index) => ({ - value: series.key, - color: barColors[index], - })); - - return ( - - ); - }} - wrapperStyle={{ - position: "absolute", - top: - legend.position === "top-center" || - legend.position === "top-left" || - legend.position === "top-right" - ? 0 - : undefined, - bottom: - legend.position === "bottom-center" || - legend.position === "bottom-left" || - legend.position === "bottom-right" - ? 0 - : undefined, - paddingBottom: - legend.position === "bottom-center" || - legend.position === "bottom-left" || - legend.position === "bottom-right" - ? "var(--static-space-40)" - : undefined, - left: - (axis === "y" || axis === "both") && - (legend.position === "top-center" || legend.position === "bottom-center") - ? "var(--static-space-64)" - : 0, - width: - (axis === "y" || axis === "both") && - (legend.position === "top-center" || legend.position === "bottom-center") - ? "calc(100% - var(--static-space-64))" - : "100%", - right: 0, - margin: 0, - }} + content={legendContent} + wrapperStyle={legendWrapperStyle} /> )} = ({ allowDataOverflow width={64} padding={{ top: 40 }} + orientation={reverseY ? "right" : "left"} tickLine={tickLine} tick={{ fill: tickFill, diff --git a/packages/core/src/modules/data/Gauge.module.css b/packages/core/src/modules/data/Gauge.module.css new file mode 100644 index 0000000..9adc465 --- /dev/null +++ b/packages/core/src/modules/data/Gauge.module.css @@ -0,0 +1,27 @@ +.svg { + display: block; + overflow: visible; +} + +.activeLine { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.inactiveLine { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.label { + font-family: var(--font-code); + user-select: none; +} + +/* Glow animation */ +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.8; + } +} diff --git a/packages/core/src/modules/data/LineBarChart.tsx b/packages/core/src/modules/data/LineBarChart.tsx index 14b65cd..50b10c5 100644 --- a/packages/core/src/modules/data/LineBarChart.tsx +++ b/packages/core/src/modules/data/LineBarChart.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo, useCallback } from "react"; import { isWithinInterval, parseISO } from "date-fns"; import { formatDate } from "./utils/formatDate"; import { @@ -33,6 +33,8 @@ import { RadiusSize } from "@/types"; interface LineBarChartProps extends ChartProps { barWidth?: barWidth; curve?: curveType; + reverseY?: boolean; + reverseX?: boolean; "data-viz-style"?: string; } @@ -54,6 +56,8 @@ const LineBarChart: React.FC = ({ variant: variantProp, barWidth = "l", curve = "natural", + reverseY = false, + reverseX = false, "data-viz-style": dataVizStyle, ...flex }) => { @@ -120,12 +124,15 @@ const LineBarChart: React.FC = ({ return data; }, [data, selectedDateRange, xAxisKey]); - const handleDateRangeChange = (newRange: DateRange) => { - setSelectedDateRange(newRange); - if (date?.onChange) { - date.onChange(newRange); - } - }; + const handleDateRangeChange = useCallback( + (newRange: DateRange) => { + setSelectedDateRange(newRange); + if (date?.onChange) { + date.onChange(newRange); + } + }, + [date], + ); const chartSeriesArray = Array.isArray(series) ? series : series ? [series] : []; if (chartSeriesArray.length < 2) { @@ -141,6 +148,56 @@ const LineBarChart: React.FC = ({ const finalLineColor = `var(--data-${lineColor})`; const finalBarColor = `var(--data-${barColor})`; + const legendContent = useMemo( + () => ( + + ), + [variant, finalLineColor, finalBarColor, axis, legend.position, legend.direction], + ); + + const legendWrapperStyle = useMemo( + () => ({ + position: "absolute" as const, + top: + legend.position === "top-center" || + legend.position === "top-left" || + legend.position === "top-right" + ? reverseX ? 32 : 0 + : undefined, + bottom: + legend.position === "bottom-center" || + legend.position === "bottom-left" || + legend.position === "bottom-right" + ? 0 + : undefined, + paddingBottom: + legend.position === "bottom-center" || + legend.position === "bottom-left" || + legend.position === "bottom-right" + ? "var(--static-space-40)" + : undefined, + left: + (axis === "y" || axis === "both") && + (legend.position === "top-center" || legend.position === "bottom-center") + ? "var(--static-space-64)" + : 0, + width: + (axis === "y" || axis === "both") && + (legend.position === "top-center" || legend.position === "bottom-center") + ? "calc(100% - var(--static-space-64))" + : "100%", + right: 0, + margin: 0, + }), + [legend.position, reverseX, axis], + ); + const chartId = React.useMemo(() => Math.random().toString(36).substring(2, 9), []); return ( @@ -191,48 +248,8 @@ const LineBarChart: React.FC = ({ {legend.display && ( - } - wrapperStyle={{ - position: "absolute", - top: - legend.position === "top-center" || - legend.position === "top-left" || - legend.position === "top-right" - ? 0 - : undefined, - bottom: - legend.position === "bottom-center" || - legend.position === "bottom-left" || - legend.position === "bottom-right" - ? 0 - : undefined, - paddingBottom: - legend.position === "bottom-center" || - legend.position === "bottom-left" || - legend.position === "bottom-right" - ? "var(--static-space-40)" - : undefined, - left: - (axis === "y" || axis === "both") && - (legend.position === "top-center" || legend.position === "bottom-center") - ? "var(--static-space-64)" - : 0, - width: - (axis === "y" || axis === "both") && - (legend.position === "top-center" || legend.position === "bottom-center") - ? "calc(100% - var(--static-space-64))" - : "100%", - right: 0, - margin: 0, - }} + content={legendContent} + wrapperStyle={legendWrapperStyle} /> )} = ({ tickMargin={6} dataKey={xAxisKey} hide={!(axis === "x" || axis === "both")} + orientation={reverseX ? "top" : "bottom"} axisLine={{ stroke: axisLineStroke, }} @@ -258,6 +276,7 @@ const LineBarChart: React.FC = ({ width={64} padding={{ top: 40 }} allowDataOverflow + orientation={reverseY ? "right" : "left"} tickLine={tickLine} tick={{ fill: tickFill, diff --git a/packages/core/src/modules/data/LineChart.tsx b/packages/core/src/modules/data/LineChart.tsx index 5abf53f..71c427b 100644 --- a/packages/core/src/modules/data/LineChart.tsx +++ b/packages/core/src/modules/data/LineChart.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useMemo, useCallback } from "react"; import { isWithinInterval, parseISO } from "date-fns"; import { formatDate } from "./utils/formatDate"; import { @@ -31,6 +31,8 @@ import { useDataTheme } from "../../contexts/DataThemeProvider"; interface LineChartProps extends ChartProps { curve?: curveType; + reverseY?: boolean; + reverseX?: boolean; "data-viz-style"?: string; } @@ -51,6 +53,8 @@ const LineChart: React.FC = ({ border = "neutral-alpha-weak", variant: variantProp, curve = "natural", + reverseY = false, + reverseX = false, "data-viz-style": dataVizStyle, ...flex }) => { @@ -92,10 +96,14 @@ const LineChart: React.FC = ({ // Generate a unique ID for this chart instance const chartId = React.useMemo(() => Math.random().toString(36).substring(2, 9), []); - const coloredSeriesArray = seriesArray.map((s, index) => ({ - ...s, - color: s.color || getDistributedColor(index, seriesArray.length), - })); + const coloredSeriesArray = useMemo( + () => + seriesArray.map((s, index) => ({ + ...s, + color: s.color || getDistributedColor(index, seriesArray.length), + })), + [seriesArray], + ); const autoKeys = Object.keys(data[0] || {}).filter((key) => !seriesKeys.includes(key)); const autoSeries = @@ -135,12 +143,72 @@ const LineChart: React.FC = ({ return data; }, [data, selectedDateRange, xAxisKey]); - const handleDateRangeChange = (newRange: DateRange) => { - setSelectedDateRange(newRange); - if (date?.onChange) { - date.onChange(newRange); - } - }; + const handleDateRangeChange = useCallback( + (newRange: DateRange) => { + setSelectedDateRange(newRange); + if (date?.onChange) { + date.onChange(newRange); + } + }, + [date], + ); + + const legendContent = useCallback( + (props: any) => { + const customPayload = autoSeries.map(({ key, color }, index) => ({ + value: key, + color: `var(--data-${color || schemes[index % schemes.length]})`, + })); + + return ( + + ); + }, + [autoSeries, axis, legend.position, legend.direction, variant], + ); + + const legendWrapperStyle = useMemo( + () => ({ + position: "absolute" as const, + top: + legend.position === "top-center" || + legend.position === "top-left" || + legend.position === "top-right" + ? reverseX ? 32 : 0 + : undefined, + bottom: + legend.position === "bottom-center" || + legend.position === "bottom-left" || + legend.position === "bottom-right" + ? 0 + : undefined, + paddingBottom: + legend.position === "bottom-center" || + legend.position === "bottom-left" || + legend.position === "bottom-right" + ? "var(--static-space-40)" + : undefined, + left: + (axis === "y" || axis === "both") && + (legend.position === "top-center" || legend.position === "bottom-center") + ? "var(--static-space-64)" + : 0, + width: + (axis === "y" || axis === "both") && + (legend.position === "top-center" || legend.position === "bottom-center") + ? "calc(100% - var(--static-space-64))" + : "100%", + right: 0, + margin: 0, + }), + [legend.position, reverseX, axis], + ); return ( = ({ {legend.display && ( { - const customPayload = autoSeries.map(({ key, color }, index) => ({ - value: key, - color: `var(--data-${color || schemes[index % schemes.length]})`, - })); - - return ( - - ); - }} - wrapperStyle={{ - position: "absolute", - top: - legend.position === "top-center" || - legend.position === "top-left" || - legend.position === "top-right" - ? 0 - : undefined, - bottom: - legend.position === "bottom-center" || - legend.position === "bottom-left" || - legend.position === "bottom-right" - ? 0 - : undefined, - paddingBottom: - legend.position === "bottom-center" || - legend.position === "bottom-left" || - legend.position === "bottom-right" - ? "var(--static-space-40)" - : undefined, - left: - (axis === "y" || axis === "both") && - (legend.position === "top-center" || legend.position === "bottom-center") - ? "var(--static-space-64)" - : 0, - width: - (axis === "y" || axis === "both") && - (legend.position === "top-center" || legend.position === "bottom-center") - ? "calc(100% - var(--static-space-64))" - : "100%", - right: 0, - margin: 0, - }} + content={legendContent} + wrapperStyle={legendWrapperStyle} /> )} = ({ tickMargin={6} dataKey={xAxisKey} hide={!(axis === "x" || axis === "both")} + orientation={reverseX ? "top" : "bottom"} axisLine={{ stroke: axisLineStroke, }} @@ -264,6 +286,7 @@ const LineChart: React.FC = ({ width={64} padding={{ top: 40 }} allowDataOverflow + orientation={reverseY ? "right" : "left"} tickLine={tickLine} tick={{ fill: tickFill, diff --git a/packages/core/src/modules/data/LinearGauge.tsx b/packages/core/src/modules/data/LinearGauge.tsx new file mode 100644 index 0000000..967d589 --- /dev/null +++ b/packages/core/src/modules/data/LinearGauge.tsx @@ -0,0 +1,149 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Column, Flex, Row, Text } from "../../"; +import styles from "./Gauge.module.css"; + +interface LinearGaugeProps extends React.ComponentProps { + width?: number; + height?: number; + line?: { + count?: number; + width?: number; + length?: number; + }; + value?: number; + labels?: "none" | "percentage" | string[]; + hue?: "success" | "neutral" | "danger" | [number, number]; + color?: string; +} + +const resolveHueRange = (hue: LinearGaugeProps["hue"]): [number, number] => { + if (hue && typeof hue !== "string") { + const [start = 200, end = 120] = hue; + return [start, end]; + } + + if (hue === "danger") return [0, 30]; + if (hue === "neutral") return [30, 60]; + if (hue === "success") return [200, 120]; + + return [200, 120]; +}; + +export const LinearGauge = ({ + width = 400, + height = 80, + line, + value = 50, + labels = "none", + hue, + color = "contrast", + ...flex +}: LinearGaugeProps) => { + const pad = 8; + + // Destructure line with individual defaults + const lineCount = line?.count ?? 48; + const lineWidth = line?.width ?? 3; + const lineLength = line?.length ?? 40; + + // Animate active tick count + const [activeLines, setActiveLines] = useState(() => Math.floor((value / 100) * lineCount)); + + useEffect(() => { + const target = Math.floor((value / 100) * lineCount); + + if (target === activeLines) return; + + let current = activeLines; + const step = target > current ? 1 : -1; + const interval = window.setInterval(() => { + current += step; + setActiveLines(current); + + if (current === target) { + window.clearInterval(interval); + } + }, 20); + + return () => { + window.clearInterval(interval); + }; + }, [value, lineCount, activeLines]); + + const hasHue = hue !== undefined; + const [startHue, endHue] = resolveHueRange(hue); + + const renderLines = () => { + const lines = []; + const spacing = (width - pad * 2) / (lineCount - 1); + + for (let j = 0; j < lineCount; j++) { + const x = pad + j * spacing; + const isActive = j < activeLines; + const gradientPosition = lineCount > 1 ? j / (lineCount - 1) : 0; + const finalHue = startHue + (endHue - startHue) * gradientPosition; + + lines.push( + + ); + } + return lines; + }; + + const renderLabels = () => { + if (labels === "none") return null; + + let labelValues: (string | number)[] = []; + + if (labels === "percentage") { + labelValues = [0, 25, 50, 75, 100].map(p => `${p}%`); + } else if (Array.isArray(labels)) { + labelValues = labels; + } + + return ( + + {labelValues.map((label, i) => ( + + {label} + + ))} + + ); + }; + + return ( + + {renderLabels()} + + + {renderLines()} + + + + ); +}; diff --git a/packages/core/src/modules/data/RadialGauge.tsx b/packages/core/src/modules/data/RadialGauge.tsx new file mode 100644 index 0000000..2be4c45 --- /dev/null +++ b/packages/core/src/modules/data/RadialGauge.tsx @@ -0,0 +1,173 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Column, CountFx, Text } from "../../"; +import styles from "./Gauge.module.css"; + +interface RadialGaugeProps extends Omit, 'direction'> { + width?: number; + height?: number; + line?: { + count?: number; + width?: number; + length?: number; + }; + unit?: React.ReactNode; + value?: number; + angle?: { + start: number; + sweep: number; + }; + direction?: 'cw' | 'ccw';// default 'cw' (arc rotation direction) + edgePad?: number; // default 0, number of ticks to trim at both ends + children?: React.ReactNode; + hue?: "success" | "neutral" | "danger" | [number, number]; + color?: string; +} + +const resolveHueRange = (hue: RadialGaugeProps["hue"]): [number, number] => { + if (hue && typeof hue !== "string") { + const [start = 200, end = 120] = hue; + return [start, end]; + } + + if (hue === "danger") return [0, 30]; + if (hue === "neutral") return [30, 60]; + if (hue === "success") return [200, 120]; + + return [200, 120]; +}; + +export const RadialGauge = ({ + width = 300, + height = 300, + line, + value = 7, + angle = { + start: 0, + sweep: 360, + }, + direction = 'cw', + edgePad = 0, + unit, + children, + hue, + color = "contrast", + ...flex +}: RadialGaugeProps) => { + const pad = 4; + + // Destructure line with individual defaults + const lineCount = line?.count ?? 48; + const lineWidth = line?.width ?? 3; + const lineLength = line?.length ?? 40; + + // For semicircles (sweepAngle ~180), use width or height as the diameter + // For full circles, use the smaller dimension + let radius: number; + let cx: number; + let cy: number; + + if (angle.sweep <= 180) { + // Semicircle: span the full width, center horizontally + radius = width / 2 - pad; + cx = width / 2; + cy = height; // bottom edge for top semicircle with startAngle=-90 + } else { + // Full or large arc: use smaller dimension + radius = Math.min(width, height) / 2 - pad; + cx = width / 2; + cy = height / 2; + } + + const ticks = Math.max(0, lineCount - edgePad * 2); + + // Animate active tick count so ticks light up one by one when the value changes + const [activeLines, setActiveLines] = useState(() => Math.floor((value / 100) * ticks)); + + useEffect(() => { + const target = Math.floor((value / 100) * ticks); + + if (target === activeLines) return; + + let current = activeLines; + const step = target > current ? 1 : -1; + const interval = window.setInterval(() => { + current += step; + setActiveLines(current); + + if (current === target) { + window.clearInterval(interval); + } + }, 20); // small delay for a smooth, sequential tick animation + + return () => { + window.clearInterval(interval); + }; + }, [value, ticks, activeLines]); + const dir = direction === 'cw' ? 1 : -1; + + // Transform user angles to intuitive system: 0°=left, 90°=top, 180°=right + // SVG rotation: 0°=up, 90°=right, so user's angle - 90 maps correctly + const internalStartAngle = angle.start - 90; + const hasHue = hue !== undefined; + const [startHue, endHue] = resolveHueRange(hue); + + const renderLines = () => { + const lines = []; + for (let j = 0; j < ticks; j++) { + // map j∈[0,ticks-1] to angle ∈ [startAngle, startAngle + sweepAngle] + const t = ticks > 1 ? j / (ticks - 1) : 0; + const finalAngle = internalStartAngle + dir * (t * angle.sweep); + const isActive = j < activeLines; + + const gradientPosition = t; // 0..1 across the arc + + const finalHue = startHue + (endHue - startHue) * gradientPosition; + + lines.push( + + ); + } + return lines; + }; + + return ( + + + + + + + + + {renderLines()} + + + + {children || ( + + {unit} + + )} + + + ); +}; \ No newline at end of file diff --git a/packages/core/src/modules/data/index.ts b/packages/core/src/modules/data/index.ts index 47172fd..0f47e4b 100644 --- a/packages/core/src/modules/data/index.ts +++ b/packages/core/src/modules/data/index.ts @@ -4,6 +4,8 @@ export * from "./LineChart"; export * from "./BarChart"; export * from "./PieChart"; export * from "./LineBarChart"; +export * from "./RadialGauge"; +export * from "./LinearGauge"; export * from "./ChartHeader"; export * from "./ChartStatus"; diff --git a/packages/core/src/modules/index.ts b/packages/core/src/modules/index.ts index 0c46494..570388f 100644 --- a/packages/core/src/modules/index.ts +++ b/packages/core/src/modules/index.ts @@ -3,20 +3,7 @@ export { MediaUpload } from "./media/MediaUpload"; export { Meta } from "./seo/Meta"; export { Schema } from "./seo/Schema"; -export { HeadingNav } from "./navigation/HeadingNav"; -export { HeadingLink } from "./navigation/HeadingLink"; -export { MegaMenu } from "./navigation/MegaMenu"; -export { MobileMegaMenu } from "./navigation/MobileMegaMenu"; -export { Kbar } from "./navigation/Kbar"; +export { Kbar, MobileMegaMenu, MegaMenu, HeadingLink, HeadingNav } from "./navigation"; -export { ChartHeader } from "./data/ChartHeader"; -export { ChartStatus } from "./data/ChartStatus"; -export type { ChartProps, ChartVariant, ChartMode, DataPoint } from "./data/interfaces"; -export { LinearGradient } from "./data/Gradient"; -export { RadialGradient } from "./data/Gradient"; -export { BarChart } from "./data/BarChart"; -export { LineChart } from "./data/LineChart"; -export { PieChart } from "./data/PieChart"; -export { LineBarChart } from "./data/LineBarChart"; -export { DataTooltip } from "./data/DataTooltip"; -export { Legend } from "./data/Legend"; +export { ChartHeader, ChartStatus, LinearGradient, RadialGradient, BarChart, LineChart, PieChart, LineBarChart, DataTooltip, Legend, RadialGauge, LinearGauge } from "./data"; +export type { ChartProps, ChartVariant, ChartMode, DataPoint } from "./data"; diff --git a/packages/core/src/modules/navigation/MegaMenu.tsx b/packages/core/src/modules/navigation/MegaMenu.tsx index 79f114b..e052424 100644 --- a/packages/core/src/modules/navigation/MegaMenu.tsx +++ b/packages/core/src/modules/navigation/MegaMenu.tsx @@ -251,8 +251,10 @@ export const MegaMenu: React.FC = ({ menuGroups, className, ...re {dropdownGroups.map((group, groupIndex) => { const isActive = activeDropdown === group.id; const wasActive = previousDropdownRef.current === group.id; - // Animate when switching between dropdowns (not when first opening or returning to same) - const shouldAnimate = isActive && !wasActive && previousDropdownRef.current !== null; + // Exiting: was active previously but not active now + const isExiting = wasActive && !isActive; + // Animate only when switching between dropdowns (not when first opening or returning to same) + const shouldAnimate = (isActive || isExiting) && previousDropdownRef.current !== null; // Update previous ref when active changes if (isActive && !wasActive) { @@ -271,11 +273,15 @@ export const MegaMenu: React.FC = ({ menuGroups, className, ...re contentRefs.current[group.id] = el; }} style={{ + zIndex: isExiting ? 3 : isActive ? 2 : 1, transform: isActive ? "scale(1)" : "scale(0.9)", - opacity: isActive ? 1 : 0, + opacity: isActive ? 1 : isExiting ? 0 : 0, pointerEvents: isActive ? "auto" : "none", - transition: shouldAnimate ? "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)" : "opacity 0.2s ease-in", - visibility: isActive ? "visible" : "hidden", + transition: shouldAnimate + ? "opacity 240ms ease, transform 240ms cubic-bezier(0.4, 0, 0.2, 1)" + : "opacity 200ms ease", + transitionDelay: shouldAnimate ? (isActive ? "120ms" : "0ms") : "0ms", + visibility: isActive || isExiting ? "visible" : "hidden", }} > {/* Render custom content if provided, otherwise render sections */}