From fde5ae916430c194de4b0d2aa5fd95f25b2f7a80 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Sat, 8 Nov 2025 14:25:47 -0600 Subject: [PATCH] feat: add function-based custom bead rendering and HTTP status code easter eggs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add dynamic custom bead rendering system that allows beads to change appearance based on their context (active state, position, place value, type, etc.). Custom Bead Features: - Add emoji-function, image-function, and svg-function types - Functions receive CustomBeadContext with bead state and style info - Support for dynamic rendering based on: active state, position, place value, bead type (heaven/earth), color, and size - Enables creative visualizations like traffic lights, themed symbols, etc. 404 Page Easter Eggs: - Create interactive 404 page with manipulable abacus - Add 14 HTTP status code easter eggs (200, 201, 301, 400, 401, 403, 418, 420, 451, 500, 503, 666, 777, 911) - Each code triggers site-wide custom bead transformation - Use function-based rendering for variety (different emojis per bead position/state) - Easter eggs persist until page reload via global AbacusDisplayContext Storybook Documentation: - Add comprehensive custom bead stories showing static and function-based usage - Include examples: active/inactive states, heaven/earth types, place value colors, traffic lights, color theming - Document CustomBeadContext API and usage patterns Technical Implementation: - Extend CustomBeadContent union type in AbacusContext - Update AbacusStaticBead and AbacusAnimatedBead to handle function types - Pass bead context (type, value, active, position, placeValue, color, size) to custom render functions - Maintain consistency across static (SSR) and animated (client) rendering ðŸĪ– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/web/src/app/not-found.tsx | 294 +++++++++++ .../abacus-react/src/AbacusAnimatedBead.tsx | 112 +++- packages/abacus-react/src/AbacusContext.tsx | 32 +- .../src/AbacusReact.customBeads.stories.tsx | 491 ++++++++++++++++++ packages/abacus-react/src/AbacusReact.tsx | 6 +- .../abacus-react/src/AbacusSVGRenderer.tsx | 8 +- .../abacus-react/src/AbacusStaticBead.tsx | 96 +++- packages/abacus-react/src/index.ts | 2 + 8 files changed, 1025 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/app/not-found.tsx create mode 100644 packages/abacus-react/src/AbacusReact.customBeads.stories.tsx diff --git a/apps/web/src/app/not-found.tsx b/apps/web/src/app/not-found.tsx new file mode 100644 index 00000000..cfa39a91 --- /dev/null +++ b/apps/web/src/app/not-found.tsx @@ -0,0 +1,294 @@ +'use client' + +import Link from 'next/link' +import { useState, useEffect } from 'react' +import type { CustomBeadContent } from '@soroban/abacus-react' +import { AbacusReact, useAbacusDisplay } from '@soroban/abacus-react' +import { PageWithNav } from '@/components/PageWithNav' +import { css } from '../../styled-system/css' +import { stack } from '../../styled-system/patterns' + +// HTTP Status Code Easter Eggs with dynamic bead rendering +const STATUS_CODE_EASTER_EGGS: Record< + number, + { customBeadContent: CustomBeadContent; message: string } +> = { + 200: { + customBeadContent: { type: 'emoji-function', value: (bead) => (bead.active ? '✅' : '⭕') }, + message: "Everything's counting perfectly!", + }, + 201: { + customBeadContent: { + type: 'emoji-function', + value: (bead) => (bead.type === 'heaven' ? 'ðŸĨš' : 'ðŸĢ'), + }, + message: 'Something new has been counted into existence!', + }, + 301: { + customBeadContent: { + type: 'emoji-function', + value: (bead) => (bead.active ? '🚚' : 'ðŸ“Ķ'), + }, + message: 'These numbers have permanently relocated!', + }, + 400: { + customBeadContent: { + type: 'emoji-function', + value: (bead) => (bead.active ? '❌' : '❓'), + }, + message: "Those numbers don't make sense!", + }, + 401: { + customBeadContent: { + type: 'emoji-function', + value: (bead) => (bead.active ? '🔒' : '🔑'), + }, + message: 'These numbers are classified!', + }, + 403: { + customBeadContent: { + type: 'emoji-function', + value: (bead) => (bead.type === 'heaven' ? 'ðŸšŦ' : '⛔'), + }, + message: "You're not allowed to count these numbers!", + }, + 418: { + customBeadContent: { type: 'emoji', value: 'ðŸŦ–' }, + message: "Perhaps you're pouring in the wrong direction?", + }, + 420: { + customBeadContent: { + type: 'emoji-function', + value: (bead) => { + const emojis = ['ðŸŒŋ', '🍃', 'ðŸŒą', 'ðŸŠī'] + return emojis[bead.position % emojis.length] || 'ðŸŒŋ' + }, + }, + message: 'Whoa dude, these numbers are like... relative, man', + }, + 451: { + customBeadContent: { + type: 'emoji-function', + value: (bead) => (bead.active ? 'ðŸĪ' : '▓'), + }, + message: '[REDACTED] - This number has been removed by the Ministry of Mathematics', + }, + 500: { + customBeadContent: { + type: 'emoji-function', + value: (bead) => { + const fireEmojis = ['ðŸ”Ĩ', 'ðŸ’Ĩ', '⚠ïļ'] + return bead.active ? fireEmojis[bead.position % fireEmojis.length] || 'ðŸ”Ĩ' : 'ðŸ’Ļ' + }, + }, + message: 'The abacus has caught fire!', + }, + 503: { + customBeadContent: { + type: 'emoji-function', + value: (bead) => { + const tools = ['🔧', 'ðŸ”Ļ', '🊛', '⚙ïļ'] + return bead.active ? tools[bead.placeValue % tools.length] || '🔧' : '⚩' + }, + }, + message: "Pardon our dust, we're upgrading the beads!", + }, + 666: { + customBeadContent: { + type: 'emoji-function', + value: (bead) => { + const demons = ['😈', 'ðŸ‘đ', '👚', '💀'] + return bead.active ? demons[bead.position % demons.length] || '😈' : 'ðŸ”Ĩ' + }, + }, + message: 'Your soul now belongs to arithmetic!', + }, + 777: { + customBeadContent: { + type: 'emoji-function', + value: (bead) => { + const lucky = ['🎰', '🍀', '💰', 'ðŸŽē', '⭐'] + return bead.active ? lucky[bead.placeValue % lucky.length] || '🎰' : '⚩' + }, + }, + message: "Jackpot! You've mastered the soroban!", + }, + 911: { + customBeadContent: { + type: 'emoji-function', + value: (bead) => { + const emergency = ['ðŸšĻ', '🚑', '🚒', 'ðŸ‘Ū'] + return bead.active ? emergency[bead.position % emergency.length] || 'ðŸšĻ' : 'âšŦ' + }, + }, + message: 'EMERGENCY: Someone needs help with their math homework!', + }, +} + +export default function NotFound() { + const [abacusValue, setAbacusValue] = useState(404) + const [activeEasterEgg, setActiveEasterEgg] = useState(null) + const { updateConfig, resetToDefaults } = useAbacusDisplay() + + // Easter egg activation - update global abacus config when special codes are entered + useEffect(() => { + const easterEgg = STATUS_CODE_EASTER_EGGS[abacusValue] + + if (easterEgg && activeEasterEgg !== abacusValue) { + setActiveEasterEgg(abacusValue) + + // Update global abacus display config to use custom beads + // This affects ALL abaci rendered in the app until page reload! + updateConfig({ + beadShape: 'custom', + customBeadContent: easterEgg.customBeadContent, + }) + + // Store active easter egg in window so it persists across navigation + ;(window as any).__easterEggMode = abacusValue + } else if (!easterEgg && activeEasterEgg !== null) { + // User changed away from an easter egg code - reset to defaults + setActiveEasterEgg(null) + resetToDefaults() + ;(window as any).__easterEggMode = null + } + }, [abacusValue, activeEasterEgg, updateConfig, resetToDefaults]) + + return ( + +
+
+ {/* Interactive Abacus */} +
+ +
+ + {/* Main message */} +
+

+ {activeEasterEgg + ? STATUS_CODE_EASTER_EGGS[activeEasterEgg].message + : "Oops! We've lost count."} +

+
+ + {/* Navigation links */} +
+ + Home + + + + Games + + + + Create + +
+ + {/* Easter egg hint */} +

+ Try other HTTP status codes... +

+
+
+
+ ) +} diff --git a/packages/abacus-react/src/AbacusAnimatedBead.tsx b/packages/abacus-react/src/AbacusAnimatedBead.tsx index 60e6d5c5..8856347c 100644 --- a/packages/abacus-react/src/AbacusAnimatedBead.tsx +++ b/packages/abacus-react/src/AbacusAnimatedBead.tsx @@ -31,6 +31,7 @@ import { useSpring, animated, to } from "@react-spring/web"; import { useDrag } from "@use-gesture/react"; import type { BeadComponentProps } from "./AbacusSVGRenderer"; import type { BeadConfig } from "./AbacusReact"; +import type { CustomBeadContext } from "./AbacusContext"; interface AnimatedBeadProps extends BeadComponentProps { // Animation controls @@ -65,6 +66,7 @@ export function AbacusAnimatedBead({ shape, color, hideInactiveBeads, + customBeadContent, customStyle, onClick, onMouseEnter, @@ -203,16 +205,6 @@ export function AbacusAnimatedBead({ const renderShape = () => { const halfSize = size / 2; - // Determine fill - use gradient for realistic mode, otherwise use color - let fillValue = customStyle?.fill || color; - if (enhanced3d === "realistic" && columnIndex !== undefined) { - if (bead.type === "heaven") { - fillValue = `url(#bead-gradient-${columnIndex}-heaven)`; - } else { - fillValue = `url(#bead-gradient-${columnIndex}-earth-${bead.position})`; - } - } - // Calculate opacity based on state and settings let opacity: number; if (customStyle?.opacity !== undefined) { @@ -232,6 +224,105 @@ export function AbacusAnimatedBead({ opacity = 1; } + // Custom bead content (emoji, image, or SVG) + if (shape === "custom" && customBeadContent) { + // Build context for function-based custom beads + const beadContext: CustomBeadContext = { + type: bead.type, + value: bead.value, + active: bead.active, + position: bead.position, + placeValue: bead.placeValue, + color, + size, + }; + + switch (customBeadContent.type) { + case "emoji": + return ( + + {customBeadContent.value} + + ); + case "emoji-function": { + const emoji = customBeadContent.value(beadContext); + return ( + + {emoji} + + ); + } + case "image": + return ( + + ); + case "image-function": { + const imageProps = customBeadContent.value(beadContext); + return ( + + ); + } + case "svg": + return ( + + ); + case "svg-function": { + const svgContent = customBeadContent.value(beadContext); + return ( + + ); + } + } + } + + // Determine fill - use gradient for realistic mode, otherwise use color + let fillValue = customStyle?.fill || color; + if (enhanced3d === "realistic" && columnIndex !== undefined) { + if (bead.type === "heaven") { + fillValue = `url(#bead-gradient-${columnIndex}-heaven)`; + } else { + fillValue = `url(#bead-gradient-${columnIndex}-earth-${bead.position})`; + } + } + const stroke = customStyle?.stroke || "#000"; const strokeWidth = customStyle?.strokeWidth || 0.5; @@ -276,6 +367,7 @@ export function AbacusAnimatedBead({ // Calculate offsets for shape positioning const getXOffset = () => { + if (shape === "custom") return size / 2; return shape === "diamond" ? size * 0.7 : size / 2; }; diff --git a/packages/abacus-react/src/AbacusContext.tsx b/packages/abacus-react/src/AbacusContext.tsx index c2e686b1..deda2021 100644 --- a/packages/abacus-react/src/AbacusContext.tsx +++ b/packages/abacus-react/src/AbacusContext.tsx @@ -15,7 +15,31 @@ export type ColorScheme = | "place-value" | "heaven-earth" | "alternating"; -export type BeadShape = "diamond" | "circle" | "square"; +export type BeadShape = "diamond" | "circle" | "square" | "custom"; + +// Bead info passed to custom bead functions +export interface CustomBeadContext { + // Bead identity + type: "heaven" | "earth"; + value: number; + active: boolean; + position: number; // 0-based position within its type group + placeValue: number; // 0=ones, 1=tens, 2=hundreds, etc. + + // Style context - so custom beads can match abacus theme + color: string; // The color that would be used for this bead + size: number; // Bead size in pixels +} + +// Custom bead content types +export type CustomBeadContent = + | { type: "emoji"; value: string } // e.g., { type: "emoji", value: "ðŸŦ–" } + | { type: "emoji-function"; value: (bead: CustomBeadContext) => string } // e.g., { type: "emoji-function", value: (bead) => bead.active ? "✅" : "⭕" } + | { type: "image"; url: string; width?: number; height?: number } // e.g., { type: "image", url: "/star.png" } + | { type: "image-function"; value: (bead: CustomBeadContext) => { url: string; width?: number; height?: number } } // Dynamic images + | { type: "svg"; content: string } // e.g., { type: "svg", content: "" } + | { type: "svg-function"; value: (bead: CustomBeadContext) => string }; // Dynamic SVG + export type ColorPalette = | "default" | "colorblind" @@ -26,6 +50,7 @@ export type ColorPalette = export interface AbacusDisplayConfig { colorScheme: ColorScheme; beadShape: BeadShape; + customBeadContent?: CustomBeadContent; // Custom bead content when beadShape is "custom" colorPalette: ColorPalette; hideInactiveBeads: boolean; coloredNumerals: boolean; @@ -80,9 +105,12 @@ function loadConfigFromStorage(): AbacusDisplayConfig { ].includes(parsed.colorScheme) ? parsed.colorScheme : DEFAULT_CONFIG.colorScheme, - beadShape: ["diamond", "circle", "square"].includes(parsed.beadShape) + beadShape: ["diamond", "circle", "square", "custom"].includes( + parsed.beadShape, + ) ? parsed.beadShape : DEFAULT_CONFIG.beadShape, + customBeadContent: parsed.customBeadContent || undefined, colorPalette: [ "default", "colorblind", diff --git a/packages/abacus-react/src/AbacusReact.customBeads.stories.tsx b/packages/abacus-react/src/AbacusReact.customBeads.stories.tsx new file mode 100644 index 00000000..f99bdc70 --- /dev/null +++ b/packages/abacus-react/src/AbacusReact.customBeads.stories.tsx @@ -0,0 +1,491 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import AbacusReact from "./AbacusReact"; + +const meta: Meta = { + title: "Soroban/AbacusReact/Custom Beads", + component: AbacusReact, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: + "Custom bead content allows you to replace standard bead shapes (diamond, circle, square) with emojis, images, or custom SVG content. Perfect for themed visualizations or fun educational contexts.", + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** + * Emoji Beads - Teapot Example + * + * Replace all beads with emoji characters! Perfect for fun themes and easter eggs. + */ +export const EmojiBeads_Teapot: Story = { + args: { + value: 418, + columns: 3, + beadShape: "custom", + customBeadContent: { + type: "emoji", + value: "ðŸŦ–", + }, + showNumbers: true, + scaleFactor: 1.5, + }, + parameters: { + docs: { + description: { + story: + "HTTP 418 'I'm a teapot' represented with teapot emoji beads! Set `beadShape='custom'` and provide `customBeadContent` with type 'emoji'.", + }, + }, + }, +}; + +/** + * Emoji Beads - Star Example + */ +export const EmojiBeads_Stars: Story = { + args: { + value: 555, + columns: 3, + beadShape: "custom", + customBeadContent: { + type: "emoji", + value: "⭐", + }, + showNumbers: true, + colorScheme: "place-value", + }, + parameters: { + docs: { + description: { + story: "Stars for rating systems or achievements!", + }, + }, + }, +}; + +/** + * Emoji Beads - Fruit Counter + */ +export const EmojiBeads_Fruit: Story = { + args: { + value: 123, + columns: 3, + beadShape: "custom", + customBeadContent: { + type: "emoji", + value: "🍎", + }, + showNumbers: true, + hideInactiveBeads: true, + }, + parameters: { + docs: { + description: { + story: "Count apples, oranges, or any fruit! Great for early math education.", + }, + }, + }, +}; + +/** + * Emoji Beads - Coins + */ +export const EmojiBeads_Coins: Story = { + args: { + value: 999, + columns: 3, + beadShape: "custom", + customBeadContent: { + type: "emoji", + value: "🊙", + }, + showNumbers: true, + scaleFactor: 1.3, + }, + parameters: { + docs: { + description: { + story: "Perfect for teaching money and currency concepts!", + }, + }, + }, +}; + +/** + * Emoji Beads - Hearts + */ +export const EmojiBeads_Hearts: Story = { + args: { + value: 143, // "I Love You" in pager code + columns: 3, + beadShape: "custom", + customBeadContent: { + type: "emoji", + value: "âĪïļ", + }, + showNumbers: true, + }, + parameters: { + docs: { + description: { + story: "Hearts for Valentine's Day or expressing love! 143 = 'I Love You'.", + }, + }, + }, +}; + +/** + * Emoji Beads - Fire + */ +export const EmojiBeads_Fire: Story = { + args: { + value: 100, + columns: 3, + beadShape: "custom", + customBeadContent: { + type: "emoji", + value: "ðŸ”Ĩ", + }, + showNumbers: true, + interactive: true, + animated: true, + }, + parameters: { + docs: { + description: { + story: "On fire! Perfect for streaks or hot topics.", + }, + }, + }, +}; + +/** + * Emoji Beads - Dice + */ +export const EmojiBeads_Dice: Story = { + args: { + value: 666, + columns: 3, + beadShape: "custom", + customBeadContent: { + type: "emoji", + value: "ðŸŽē", + }, + showNumbers: true, + }, + parameters: { + docs: { + description: { + story: "Dice for probability and gaming applications!", + }, + }, + }, +}; + +/** + * Emoji Beads - Abacus Inception! + * + * An abacus made of tiny abacus beads! Meta abacus counting. + */ +export const EmojiBeads_AbacusInception: Story = { + args: { + value: 1234, + columns: 4, + beadShape: "custom", + customBeadContent: { + type: "emoji", + value: "ðŸ§Ū", + }, + showNumbers: true, + scaleFactor: 1.4, + }, + parameters: { + docs: { + description: { + story: + "Abacus-ception! An abacus made of tiny abacus emojis. We need to go deeper... ðŸ§Ū", + }, + }, + }, +}; + +/** + * Interactive Custom Beads + * + * Custom beads work with all interactive features! + */ +export const Interactive_CustomBeads: Story = { + args: { + value: 42, + columns: 2, + beadShape: "custom", + customBeadContent: { + type: "emoji", + value: "ðŸŽŊ", + }, + showNumbers: true, + interactive: true, + animated: true, + gestures: true, + }, + parameters: { + docs: { + description: { + story: + "Custom beads support all interactivity - click, drag, and animate just like standard shapes!", + }, + }, + }, +}; + +/** + * Image Beads Example + * + * Use custom images as beads! Images scale to fit bead size. + */ +export const ImageBeads_Example: Story = { + args: { + value: 25, + columns: 2, + beadShape: "custom", + customBeadContent: { + type: "image", + url: "https://via.placeholder.com/50/ff6b35/ffffff?text=★", + }, + showNumbers: true, + scaleFactor: 1.5, + }, + parameters: { + docs: { + description: { + story: + "Use image URLs for custom bead graphics. Images automatically scale to fit the bead size.", + }, + }, + }, +}; + +/** + * Comparison: Standard vs Custom Beads + */ +export const Comparison_StandardVsCustom: Story = { + render: () => ( +
+
+

Standard Diamond Beads

+ +
+ +
+

Custom Emoji Beads (ðŸŦ–)

+ +
+ +
+

Custom Emoji Beads (⭐)

+ +
+
+ ), + parameters: { + docs: { + description: { + story: "Side-by-side comparison of standard and custom bead shapes.", + }, + }, + }, +}; + +/** + * Custom Beads with Theme Styling + */ +export const CustomBeads_WithThemes: Story = { + args: { + value: 789, + columns: 3, + beadShape: "custom", + customBeadContent: { + type: "emoji", + value: "💎", + }, + showNumbers: true, + colorScheme: "place-value", + hideInactiveBeads: true, + }, + parameters: { + docs: { + description: { + story: + "Custom beads work seamlessly with all color schemes and styling options!", + }, + }, + }, +}; + +/** + * Function-Based Custom Beads - Active/Inactive States + * + * Use a function to render different emojis based on bead state! + */ +export const Function_ActiveInactive: Story = { + args: { + value: 123, + columns: 3, + beadShape: "custom", + customBeadContent: { + type: "emoji-function", + value: (bead) => (bead.active ? "✅" : "⭕"), + }, + showNumbers: true, + }, + parameters: { + docs: { + description: { + story: + "Different emojis for active (✅) vs inactive (⭕) beads! The function receives bead context and returns the appropriate emoji.", + }, + }, + }, +}; + +/** + * Function-Based Custom Beads - Heaven vs Earth + * + * Different emojis based on bead type! + */ +export const Function_HeavenEarth: Story = { + args: { + value: 567, + columns: 3, + beadShape: "custom", + customBeadContent: { + type: "emoji-function", + value: (bead) => (bead.type === "heaven" ? "☁ïļ" : "🌍"), + }, + showNumbers: true, + }, + parameters: { + docs: { + description: { + story: + "Heaven beads (☁ïļ) and Earth beads (🌍) with different emojis based on bead type!", + }, + }, + }, +}; + +/** + * Function-Based Custom Beads - Place Value Colors + * + * Use different emojis per column (place value)! + */ +export const Function_PlaceValue: Story = { + args: { + value: 999, + columns: 3, + beadShape: "custom", + customBeadContent: { + type: "emoji-function", + value: (bead) => { + const emojis = ["ðŸŸĒ", "ðŸ”ĩ", "ðŸ”ī", "ðŸŸĄ", "ðŸŸĢ"]; + return emojis[bead.placeValue] || "⚩"; + }, + }, + showNumbers: true, + }, + parameters: { + docs: { + description: { + story: + "Different colored circles for each place value (ones=green, tens=blue, hundreds=red)!", + }, + }, + }, +}; + +/** + * Function-Based Custom Beads - Traffic Light Pattern + * + * Complex logic: traffic lights based on active state AND position! + */ +export const Function_TrafficLights: Story = { + args: { + value: 234, + columns: 3, + beadShape: "custom", + customBeadContent: { + type: "emoji-function", + value: (bead) => { + if (!bead.active) return "âšŦ"; + if (bead.type === "heaven") return "ðŸ”ī"; // Heaven beads = red + // Earth beads cycle through traffic light colors by position + const colors = ["ðŸŸĒ", "ðŸŸĄ", "ðŸ”ī", "🟠"]; + return colors[bead.position] || "⚩"; + }, + }, + showNumbers: true, + interactive: true, + }, + parameters: { + docs: { + description: { + story: + "Traffic light pattern! Heaven beads are red, earth beads cycle through colors by position. Inactive beads are dark. Try clicking to see it change!", + }, + }, + }, +}; + +/** + * Function-Based Custom Beads - Themed by Color + * + * Use the bead's color property to choose themed emojis! + */ +export const Function_ColorThemed: Story = { + args: { + value: 456, + columns: 3, + beadShape: "custom", + customBeadContent: { + type: "emoji-function", + value: (bead) => { + // Use the color to determine emoji theme + if (bead.color.includes("red") || bead.color.includes("f00")) + return "🍎"; + if (bead.color.includes("blue") || bead.color.includes("00f")) + return "ðŸ”ĩ"; + if (bead.color.includes("green") || bead.color.includes("0f0")) + return "🍏"; + return bead.active ? "⭐" : "⚩"; + }, + }, + showNumbers: true, + colorScheme: "place-value", + }, + parameters: { + docs: { + description: { + story: + "Emojis chosen based on the bead's color! Red beads = apples, blue = circles, green = green apples.", + }, + }, + }, +}; diff --git a/packages/abacus-react/src/AbacusReact.tsx b/packages/abacus-react/src/AbacusReact.tsx index 578291b5..a89fa5ce 100644 --- a/packages/abacus-react/src/AbacusReact.tsx +++ b/packages/abacus-react/src/AbacusReact.tsx @@ -267,7 +267,8 @@ export interface AbacusConfig { columns?: number | "auto"; showEmptyColumns?: boolean; hideInactiveBeads?: boolean; - beadShape?: "diamond" | "square" | "circle"; + beadShape?: "diamond" | "square" | "circle" | "custom"; + customBeadContent?: import("./AbacusContext").CustomBeadContent; // Custom emoji, image, or SVG colorScheme?: "monochrome" | "place-value" | "alternating" | "heaven-earth"; colorPalette?: "default" | "colorblind" | "mnemonic" | "grayscale" | "nature"; scaleFactor?: number; @@ -1594,6 +1595,7 @@ export const AbacusReact: React.FC = ({ showEmptyColumns = false, hideInactiveBeads, beadShape, + customBeadContent, colorScheme, colorPalette, scaleFactor, @@ -1643,6 +1645,7 @@ export const AbacusReact: React.FC = ({ const finalConfig = { hideInactiveBeads: hideInactiveBeads ?? contextConfig.hideInactiveBeads, beadShape: beadShape ?? contextConfig.beadShape, + customBeadContent: customBeadContent ?? contextConfig.customBeadContent, colorScheme: colorScheme ?? contextConfig.colorScheme, colorPalette: colorPalette ?? contextConfig.colorPalette, scaleFactor: scaleFactor ?? contextConfig.scaleFactor, @@ -2308,6 +2311,7 @@ export const AbacusReact: React.FC = ({ dimensions={standardDims} scaleFactor={finalConfig.scaleFactor} beadShape={finalConfig.beadShape} + customBeadContent={finalConfig.customBeadContent} colorScheme={finalConfig.colorScheme} colorPalette={finalConfig.colorPalette} hideInactiveBeads={finalConfig.hideInactiveBeads} diff --git a/packages/abacus-react/src/AbacusSVGRenderer.tsx b/packages/abacus-react/src/AbacusSVGRenderer.tsx index de0b8361..1d7ba9aa 100644 --- a/packages/abacus-react/src/AbacusSVGRenderer.tsx +++ b/packages/abacus-react/src/AbacusSVGRenderer.tsx @@ -54,9 +54,10 @@ export interface BeadComponentProps { x: number; y: number; size: number; - shape: "circle" | "diamond" | "square"; + shape: "circle" | "diamond" | "square" | "custom"; color: string; hideInactiveBeads: boolean; + customBeadContent?: import("./AbacusContext").CustomBeadContent; customStyle?: { fill?: string; stroke?: string; @@ -84,7 +85,8 @@ export interface AbacusSVGRendererProps { scaleFactor?: number; // Appearance - beadShape: "circle" | "diamond" | "square"; + beadShape: "circle" | "diamond" | "square" | "custom"; + customBeadContent?: import("./AbacusContext").CustomBeadContent; colorScheme: string; colorPalette: string; hideInactiveBeads: boolean; @@ -141,6 +143,7 @@ export function AbacusSVGRenderer({ dimensions, scaleFactor = 1, beadShape, + customBeadContent, colorScheme, colorPalette, hideInactiveBeads, @@ -405,6 +408,7 @@ export function AbacusSVGRenderer({ y: position.y, size: beadSize, shape: beadShape, + customBeadContent, color, hideInactiveBeads, customStyle, diff --git a/packages/abacus-react/src/AbacusStaticBead.tsx b/packages/abacus-react/src/AbacusStaticBead.tsx index cfa7f97f..4223f8b0 100644 --- a/packages/abacus-react/src/AbacusStaticBead.tsx +++ b/packages/abacus-react/src/AbacusStaticBead.tsx @@ -4,15 +4,17 @@ */ import type { BeadConfig, BeadStyle } from "./AbacusReact"; +import type { CustomBeadContent, CustomBeadContext } from "./AbacusContext"; export interface StaticBeadProps { bead: BeadConfig; x: number; y: number; size: number; - shape: "diamond" | "square" | "circle"; + shape: "diamond" | "square" | "circle" | "custom"; color: string; customStyle?: BeadStyle; + customBeadContent?: CustomBeadContent; hideInactiveBeads?: boolean; } @@ -24,6 +26,7 @@ export function AbacusStaticBead({ shape, color, customStyle, + customBeadContent, hideInactiveBeads = false, }: StaticBeadProps) { // Don't render inactive beads if hideInactiveBeads is true @@ -39,6 +42,7 @@ export function AbacusStaticBead({ // Calculate offset based on shape (matching AbacusReact positioning) const getXOffset = () => { + if (shape === "custom") return halfSize; return shape === "diamond" ? size * 0.7 : halfSize; }; @@ -49,6 +53,96 @@ export function AbacusStaticBead({ const transform = `translate(${x - getXOffset()}, ${y - getYOffset()})`; const renderShape = () => { + // Custom bead content (emoji, image, or SVG) + if (shape === "custom" && customBeadContent) { + // Build context for function-based custom beads + const beadContext: CustomBeadContext = { + type: bead.type, + value: bead.value, + active: bead.active, + position: bead.position, + placeValue: bead.placeValue, + color, + size, + }; + + switch (customBeadContent.type) { + case "emoji": + return ( + + {customBeadContent.value} + + ); + case "emoji-function": { + const emoji = customBeadContent.value(beadContext); + return ( + + {emoji} + + ); + } + case "image": + return ( + + ); + case "image-function": { + const imageProps = customBeadContent.value(beadContext); + return ( + + ); + } + case "svg": + return ( + + ); + case "svg-function": { + const svgContent = customBeadContent.value(beadContext); + return ( + + ); + } + } + } + + // Standard shapes switch (shape) { case "diamond": return ( diff --git a/packages/abacus-react/src/index.ts b/packages/abacus-react/src/index.ts index ab166e8e..a8e31574 100644 --- a/packages/abacus-react/src/index.ts +++ b/packages/abacus-react/src/index.ts @@ -26,6 +26,8 @@ export type { ColorScheme, BeadShape, ColorPalette, + CustomBeadContent, + CustomBeadContext, AbacusDisplayConfig, AbacusDisplayContextType, } from "./AbacusContext";