Designing D3 into Partas.Solid

Lessons from Fabulous and FluentUI to create a principled visual language for the SPEC stack
work-single-image

The convergence of Partas.Solid (web site) and D3.js represents a strategic opportunity to establish a new paradigm for data visualization in functional web development. This proposal outlines a principled architectural approach that would leverage the synergies between Solid.JS’s fine-grained reactivity and D3’s functional computation model.

πŸ”‘ D3.js has long been misunderstood as a “DOM manipulation library.” In reality, its most powerful modules (d3-scale, d3-shape, d3-array, d3-interpolate) are pure functional computation engines that have no dependency on the DOM whatsoever. By separating computation from rendering, D3’s architecture naturally aligns with functional programming principles.

Why This Matters for F# Developers: Partas.Solid (GitHub repo) represents a more idiomatic F# approach to web development in the Fable ecosystem. Unlike Feliz, which leverages React’s model with hooks and components, Partas.Solid embraces Solid.JS’s “vanishing components” pattern where reactive primitives (signals) exist independently of component boundaries. This aligns perfectly with functional programming principles: pure functions, immutable data structures, and explicit data flow at boundaries. By building chart components as native Partas.Solid components that use D3 for computation, this approach would keep the entire visualization pipeline visible to the F# compiler, maximizing the number of design-time choices.

On Convergent Design

Through principled architectural exploration, this proposal has revealed an interesting property: the patterns that would naturally emerge from combining Partas.Solid’s reactivity model with D3’s functional approach closely mirror those used in the Fabulous framework. This convergence wasn’t a design goal. It emerged from independent, principled decisions about how to build functional UI for the web.

The shared patterns that would emerge include:

  • MVU/Elmish architecture (pure update functions, message dispatch)
  • Declarative composition (computation expressions for UI hierarchies)
  • Fine-grained reactivity (Solid’s signals parallel Fabulous’s efficient diffing)
  • Type-safe component trees (F#’s type system validates structure at compile time)

This convergence is significant because it suggests these patterns represent fundamental properties of functional UI design, not framework-specific choices. Whether targeting the web (proposed SPEC Stack approach), native mobile (Fabulous), or embedded systems (FidelityUI), applying F# and MVU principles consistently leads to similar architectural solutions.

The practical value: Developers familiar with Fabulous would find the proposed SPEC Stack patterns intuitive, and vice versa. This shared visual language would emerge as an organic property of functional design, not through deliberate imitation, making it easier to port UI concepts across domains without compromising each platform’s unique advantages.

Critically, this convergence would not require compromising Partas.Solid’s strengths. The similarity would arise naturally because both approaches share foundational principles (Elmish, computation expressions, fine-grained reactivity), while each remains optimized for its target platform.

Part I: The Partas.Solid Architecture

%%{init: {'theme': 'neutral'}}%% flowchart TD A[F# Source Code
Partas.Solid Components] B[Fable Compiler
F# β†’ JavaScript AST] C[Partas.Solid Plugin
AST Transformations] D[D3 Computation Layer
Pure Functions: scales, shapes, statistics] E[Solid.JS Reactivity
Signals & Fine-Grained DOM Updates] F[SVG Rendering
Direct DOM with Fluent UI Design Tokens] A -->|compile| B B -->|transform| C C -->|optimize| E A -->|imports| D E -->|reactive updates| F D -->|compute values| F style A fill:#e1f5ff style B fill:#fff3cd style C fill:#d4edda style D fill:#f8d7da style E fill:#e1f5ff style F fill:#fff3cd

The entire pipeline from F# source to rendered pixels is visible to the compiler, enabling whole-program optimizations.

How Partas.Solid Differs from Feliz

The Fable ecosystem has traditionally centered around Feliz, which provides F# bindings to React. While Feliz is powerful, it inherits React’s conceptual model: components as state machines that re-render on state changes, hooks for lifecycle management, and a virtual DOM for reconciliation. This is a long-standing proven model, but it carries some computational baggage that doesn’t burden SolidJS. So, distinct choices have been made to align to Partas.Solid’s model.

Feliz Pattern (React-Inspired):

// Feliz component with React hooks
open Feliz

[<ReactComponent>]
let Counter() =
    let count, setCount = React.useState(0)

    Html.div [
        Html.button [
            prop.text $"Count: {count}"
            prop.onClick (fun _ -> setCount(count + 1))
        ]
    ]

In this pattern:

  • Components are stateful entities that encapsulate behavior
  • State lives inside component boundaries via React.useState
  • Re-renders happen at the component level (entire subtree re-evaluates)
  • Lifecycle is tied to mounting/unmounting components

Partas.Solid Pattern (Signal-Driven):

// Partas.Solid component with signals
open Partas.Solid

[<SolidComponent>]
let Counter() =
    let count, setCount = createSignal(0)

    div() {
        button() {
            text $"Count: {count()}"
            onClick (fun _ -> setCount(count() + 1))
        }
    }

Notable differences:

  1. Components Execute Once: The Counter function runs exactly once when instantiated. It does not re-run on state changes. The component “vanishes” after initial execution, leaving behind a reactive graph of signals and effects.

  2. Signals Live Independently: createSignal returns an accessor function (count()) and a setter. These exist in closures, not component state. They can be passed anywhere, called from anywhere, and their lifecycle is independent of React-style mounting.

  3. Fine-Grained Reactivity: When count() changes, only the specific DOM text node bound to that signal updates. There is no component re-render, no virtual DOM diffing, no reconciliation phase. Solid.JS tracks dependencies at the expression level and updates the minimal set of affected nodes. This reactivity model delivers 2-3x performance improvements over React as shown in independent benchmarks.

  4. Builder Syntax with Computation Expressions: Partas.Solid uses F#’s computation expression syntax (div() { ... }) instead of list-based DSL (Html.div [ ... ]). This feels more natural in F# and leverages the language’s syntactic capabilities.

  5. Erased Types for Zero-Cost Abstraction: Partas.Solid extensively uses [<Erase>] attributes to create F# types that compile to JavaScript with no runtime overhead. This enables rich type safety in F# while generating minimal JavaScript output.

Why This Matters for D3 Integration

The signal-driven, “vanishing component” model of Partas.Solid aligns perfectly with D3’s functional nature. Consider a scenario where we need to render a bar chart:

Feliz/React Approach (Suboptimal):

[<ReactComponent>]
let BarChart(data: ChartData[]) =
    // Problem: This runs on EVERY render
    // D3 scales get recreated, even if data hasn't changed
    let xScale =
        d3.scaleBand()
            .domain(data |> Array.map (fun d -> d.label))
            .range([| 0.0; 600.0 |])

    let yScale =
        d3.scaleLinear()
            .domain([| 0.0; data |> Array.map (fun d -> d.value) |> Array.max |])
            .range([| 400.0; 0.0 |])

    Html.svg [
        prop.width 600
        prop.height 400
        prop.children [
            for d in data do
                Html.rect [
                    prop.x (xScale.Invoke(d.label))
                    prop.y (yScale.Invoke(d.value))
                    // ...
                ]
        ]
    ]

Issues:

  • Scales recalculated on every render (expensive for large datasets)
  • Memoization required (React.useMemo) to avoid unnecessary work
  • Component re-renders entire SVG tree when data changes

Partas.Solid Approach (Optimal):

[<SolidComponent>]
let BarChart(data: unit -> ChartData[]) =
    // This closure runs ONCE
    // Scales are functions that compute reactively when data() changes
    let xScale() =
        d3.scaleBand()
            .domain(data() |> Array.map (fun d -> d.label))
            .range([| 0.0; 600.0 |])

    let yScale() =
        d3.scaleLinear()
            .domain([| 0.0; data() |> Array.map (fun d -> d.value) |> Array.max |])
            .range([| 400.0; 0.0 |])

    svg() {
        width 600
        height 400

        For'(each'=data(), children=(fun (d, i) ->
            rect() {
                x (xScale().Invoke(d.label))
                y (yScale().Invoke(d.value))
                // ...
            }
        ))
    }

Benefits:

  • Scales are computed accessors that only recalculate when data() is accessed
  • Solid’s reactivity tracks that xScale() depends on data()
  • When data changes, only the specific rect attributes update, not the entire component
  • No memoization needed; reactive graph handles optimization automatically

This pattern, D3 as pure computation wrapped in signal accessors, would form the foundation of this proposed architecture.

Data Flow Visualization

%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant User as User Action participant Elmish as Elmish Update participant Store as Solid Store participant Chart as Chart Component participant D3 as D3 Scales participant DOM as SVG Elements User->>Elmish: Dispatch message Elmish->>Store: Update store signal Store->>Chart: Signal change detected Chart->>D3: Compute xScale(), yScale() D3-->>Chart: Return scale functions Chart->>DOM: Update rect attributes DOM-->>User: Visual update Note over Store, Chart: Fine-grained reactivity:
Only changed signals trigger recompute Note over D3: Pure functions:
Scales recomputed only when data() accessed

Solid’s fine-grained reactivity means only the specific SVG attributes that depend on changed signals are updated, no virtual DOM diffing or component re-renders.


Part II: D3 as Mathematics, Not DOM

The Misconception: D3 as a Rendering Library

D3.js (Data-Driven Documents) is often introduced with examples like this:

// Classic D3 "Hello World" - DOM manipulation
d3.select("body")
  .append("svg")
  .attr("width", 600)
  .attr("height", 400)
  .selectAll("circle")
  .data([10, 20, 30])
  .enter()
  .append("circle")
  .attr("cx", (d, i) => i * 50)
  .attr("cy", 200)
  .attr("r", d => d);

This code performs imperative DOM manipulation: selecting elements, appending nodes, setting attributes through chained method calls. Developers naturally assume D3 is a “rendering library” akin to React or Vue. This is incorrect.

D3’s Modular Architecture

D3 v4+, released in 2016, is a collection of independent modules documented at d3js.org:

Only d3-selection manipulates the DOM. All other modules are pure functional libraries with no DOM dependencies:

  • d3-scale: Mathematical functions that map input domains to output ranges
  • d3-shape: Path generators that produce SVG path strings from data
  • d3-array: Statistical operations (max, min, mean, quantile, bins)
  • d3-interpolate: Value interpolation for animations and color scales
  • d3-color: Color space conversions and manipulations
  • d3-time: Time interval calculations
  • d3-format: Number and date formatting

These modules can run in Node.js, Web Workers, or any JavaScript environment, no DOM required.

Functional D3: Examples

Example 1: Scales as Pure Functions

open Fable.Core
open Fable.Core.JsInterop

// d3-scale bindings
[<ImportMember("d3-scale")>]
let scaleLinear<'Domain, 'Range>() : obj = jsNative

// Usage: Pure function composition
let createYScale (data: float[]) (height: float) =
    let scale = scaleLinear()
    scale?domain([| 0.0; Array.max data |]) |> ignore
    scale?range([| height; 0.0 |]) |> ignore
    scale

// Invoke scale as a function
let yPosition value scale =
    scale $ value  // scale(value) in JavaScript

This createYScale function:

  • Takes data and height as inputs
  • Returns a scale function
  • Has no side effects
  • Can be composed, memoized, tested in isolation

Example 2: Shape Generators as Path Computers

open Fable.Core.JsInterop

[<ImportMember("d3-shape")>]
let line<'T>() : obj = jsNative

type Point = { x: float; y: float }

let createLineGenerator (xScale: obj) (yScale: obj) =
    let generator = line<Point>()
    generator?x(fun (p: Point) -> xScale $ p.x) |> ignore
    generator?y(fun (p: Point) -> yScale $ p.y) |> ignore
    generator

// Usage: Pure transformation
let points = [| { x = 1.0; y = 10.0 }; { x = 2.0; y = 20.0 } |]
let pathData : string = generator $ points  // Returns "M 10,100 L 20,80" (SVG path string)

The line generator:

  • Accepts accessor functions (how to extract x/y from data)
  • Returns a string (SVG path syntax)
  • Never touches the DOM
  • Is a pure function: same inputs β†’ same outputs

Example 3: Array Statistics

[<ImportMember("d3-array")>]
let max<'T>(data: 'T[], accessor: 'T -> float) : float = jsNative

[<ImportMember("d3-array")>]
let extent<'T>(data: 'T[], accessor: 'T -> float) : float * float = jsNative

type SalesData = { month: string; revenue: float }

let salesData = [|
    { month = "Jan"; revenue = 100.0 }
    { month = "Feb"; revenue = 150.0 }
|]

// Pure functional operations
let maxRevenue = max(salesData, fun d -> d.revenue)  // 150.0
let (minRev, maxRev) = extent(salesData, fun d -> d.revenue)  // (100.0, 150.0)

No DOM, no side effects, just data transformation.

Reactive Scale Computation Lifecycle

%%{init: {'theme': 'neutral'}}%% stateDiagram-v2 [*] --> ChartMounted: Component initializes ChartMounted --> ScalesComputed: Compute xScale(), yScale() ScalesComputed --> Rendered: Render SVG elements Rendered --> Idle: Chart displayed Idle --> DataChanged: data() signal updates DataChanged --> ScalesRecomputed: xScale() and yScale() rerun ScalesRecomputed --> DOMUpdated: Only changed attributes update DOMUpdated --> Idle: Visual synchronized Idle --> DimensionsChanged: Container resized DimensionsChanged --> ScalesRecomputed note right of ScalesComputed D3 scales computed as reactive signal accessors function xScale() { ... } end note note right of ScalesRecomputed Only scale functions recompute Pure functional - no side effects Solid tracks dependencies automatically end note note right of DOMUpdated Solid updates specific attributes: rect x="..." y="..." height="..." Not entire component tree end note

Scales are reactive functions that only recompute when their dependencies (data, dimensions) change. Solid’s fine-grained reactivity ensures minimal work on updates.


Part III: Native Partas.Solid Chart Components

The Proposed Architecture: D3 + Solid.JS Reactivity

The architectural plum here is that Partas.Solid components with D3 computation would create a fully transparent compilation pipeline. Unlike approaches that wrap external components, everything in this proposal would be F# source code that flows through Fable.

Core Pattern:

[<SolidComponent>]
let BarChart(data: unit -> ChartDatum[]) =
    // Scales as computed accessors (reactive)
    let xScale() =
        d3.scaleBand()
            .domain(data() |> Array.map (fun d -> d.label))
            .range([| 0.0; 600.0 |])

    let yScale() =
        d3.scaleLinear()
            .domain([| 0.0; data() |> Array.map (fun d -> d.value) |> Array.max |])
            .range([| 400.0; 0.0 |])

    svg() {
        width 600
        height 400

        For'(each'=data(), children=(fun (d, i) ->
            rect() {
                x (xScale().Invoke(d.label))
                y (yScale().Invoke(d.value))
                width (xScale().bandwidth())
                height (400.0 - yScale().Invoke(d.value))
                fill "var(--colorBrandBackground)"
            }
        ))
    }

What Would Make This Powerful:

  1. Component Runs Once: The BarChart function would execute once, creating the reactive graph
  2. Scales Are Reactive: xScale() and yScale() would recompute only when data() changes
  3. Fine-Grained Updates: Solid would track that rect attributes depend on scales, which depend on data
  4. No Virtual DOM: Changes would flow directly to specific DOM attributes
  5. Compiler Visibility: The entire pipeline would be F# AST visible to Fable and Partas.Solid plugins

Design Precedent: FluentUI.Charts

Microsoft’s @fluentui/react-charting library (npm package) demonstrates one implementation of the D3 for computation, framework for rendering pattern. While this proposal follows similar principles, the architecture emerges from Fabulous-style functional patterns rather than React’s component model.

FluentUI.Charts Pattern (React):

class VerticalBarChart extends React.Component {
  render() {
    // D3 computes scales (pure functions)
    const xScale = d3ScaleBand()
      .domain(this.props.data.map(d => d.label))
      .range([0, this.state.chartWidth]);

    const yScale = d3ScaleLinear()
      .domain([0, d3Max(this.props.data, d => d.value)])
      .range([this.state.chartHeight, 0]);

    // React renders declaratively
    return (
      <svg>
        {this.props.data.map((d, i) => (
          <rect
            x={xScale(d.label)}
            y={yScale(d.value)}
            fill={this.props.colors[i]}
          />
        ))}
      </svg>
    );
  }
}

Our Partas.Solid Pattern:

[<SolidComponent>]
let BarChart(data: unit -> ChartDatum[], colors: string[]) =
    let xScale() =
        scaleBand()
            .domain(data() |> Array.map (fun d -> d.label))
            .range([| 0.0; 600.0 |])

    let yScale() =
        scaleLinear()
            .domain([| 0.0; data() |> Array.map (fun d -> d.value) |> Array.max |])
            .range([| 400.0; 0.0 |])

    svg() {
        For'(each'=data(), children=(fun (d, i) ->
            rect() {
                x (xScale().Invoke(d.label))
                y (yScale().Invoke(d.value))
                fill colors.[i]
            }
        ))
    }

Points of Differentiation:

  • FluentUI.Charts: Scales recalculated on every React render
  • Proposed approach: Scales would be computed reactively only when dependencies change
  • FluentUI.Charts: Entire component re-renders
  • Proposed approach: Only changed attributes would update

Compilation Pipeline Visibility

%%{init: {'theme': 'neutral'}}%% flowchart TD A[Elmish Model] --> B[Solid Store] B --> C[Chart Signal] C --> D[D3 Scale Computation] D --> E[SVG Attribute Values] E --> F[Rendered Visualization] G[Fable Compiler
Sees Entire Flow] G -.->|analyze| A G -.->|analyze| B G -.->|analyze| C G -.->|analyze| D G -.->|optimize| E style G fill:#d4edda,stroke:#28a745,stroke-width:3px style A fill:#e1f5ff style B fill:#e1f5ff style C fill:#e1f5ff style D fill:#f8d7da style E fill:#fff3cd style F fill:#fff3cd

Because everything is F# source code, the compiler can analyze data flow across the entire visualization pipeline and can apply optimizations at the AST level.

Common Chart Patterns

1. Responsive Sizing

Following established patterns (as seen in FluentUI.Charts and similar libraries), this proposal would use ResizeObserver for responsive behavior:

[<SolidComponent>]
let ResponsiveChart(data: unit -> ChartDatum[]) =
    let containerRef, setContainerRef = createSignal<HTMLElement option>(None)
    let dimensions, setDimensions = createSignal({ width = 600.0; height = 400.0 })

    createEffect(fun () ->
        match containerRef() with
        | Some el ->
            let observer = ResizeObserver(fun entries ->
                let entry = entries.[0]
                setDimensions({
                    width = entry.contentRect.width
                    height = entry.contentRect.height
                })
            )
            observer.observe(el)
        | None -> ()
    )

    div() {
        ref setContainerRef
        class' "chart-container"

        svg() {
            width (dimensions().width)
            height (dimensions().height)
            // ... chart content
        }
    }

2. Accessibility (ARIA)

Following accessibility best practices:

[<SolidComponent>]
let AccessibleBarChart(data: unit -> ChartDatum[], title: string) =
    svg() {
        role "img"
        ariaLabel $"{title}: Bar chart with {data().Length} data points"

        For'(each'=data(), children=(fun (d, i) ->
            rect() {
                role "graphics-symbol"
                ariaLabel $"{d.label}: {d.value}"
                // ... positioning
            }
        ))
    }

3. Design Token System

Following modern theming patterns with CSS custom properties:

// Chart uses CSS variables that reference Fluent tokens
rect() {
    fill "var(--chart-bar-fill, var(--colorBrandBackground))"
    stroke "var(--chart-bar-stroke, var(--colorNeutralStroke1))"
}
/* Design tokens */
:root {
  --chart-bar-fill: var(--colorBrandBackground);
  --chart-bar-stroke: var(--colorNeutralStroke1);
  --chart-grid-color: var(--colorNeutralStroke2);
  --chart-text-color: var(--colorNeutralForeground1);
}

Part IV: Design System Integration via Hugo Themes

This proposal integrates with your existing Hugo theme’s design system through Victor CLI, our planned tool for transforming Hugo templates into Partas.Solid components. Rather than introducing a separate design system, charts would inherit styling from your Hugo theme automaticallyβ€”whether that’s DaisyUI, Tailwind, or any other CSS framework your theme uses.

How Victor Bridges Hugo Themes and Partas.Solid Charts

Victor CLI transforms Hugo templates (with their embedded DaisyUI, Tailwind, or custom CSS) into Partas.Solid components. This transformation preserves the design tokens and theme variables from your Hugo theme, making them available to chart components automatically.

The key insight from FluentUI.Charts: They demonstrated that D3 can be used purely for computation (scales, shapes, statistics) while the framework handles rendering. We borrow this architectural patternβ€”D3 for math, Partas.Solid for reactivity, your Hugo theme for design.

Hugo Theme (DaisyUI/Tailwind/Custom CSS)
    ↓
Victor transforms templates β†’ Partas.Solid components
    ↓
Charts consume same CSS variables
    ↓
Unified visual language across static and dynamic content

Theme Application Workflow

%%{init: {'theme': 'neutral'}}%% flowchart TB A[Hugo Theme Files] --> B[Victor CLI Transform] B --> C[Partas.Solid Components] C --> D{CSS Variables from Theme} D -->|DaisyUI Tokens| E[Chart Styling] D -->|Tailwind Utilities| E D -->|Custom Theme Vars| E E --> F[SVG Element Styles] F --> G[Consistent Visual Design] style A fill:#e1f5ff style C fill:#fff3e0 style G fill:#d4edda

Theme changes happen via CSS custom property updates, no component re-renders needed. The browser’s CSS cascade handles the visual updates automatically.

Design Token Integration

Your Hugo theme already defines CSS custom properties for colors, typography, and spacing. Charts would consume these same variables, ensuring visual consistency across your entire site.

Example: DaisyUI Theme Variables (common in Hugo themes):

/* DaisyUI automatically generates these from your theme config */
:root {
  --p: 259 94% 51%;      /* primary color (HSL) */
  --s: 314 100% 47%;     /* secondary color */
  --a: 174 60% 51%;      /* accent color */
  --n: 219 14% 28%;      /* neutral color */
  --b1: 0 0% 100%;       /* base background */
  --bc: 215 28% 17%;     /* base content (text) */

  /* Rounded corners */
  --rounded-box: 1rem;
  --rounded-btn: 0.5rem;

  /* Animations */
  --animation-btn: 0.25s;
  --animation-input: 0.2s;
}

Chart-Specific Token Mapping:

Charts would reference theme tokens through simple CSS custom property mappings:

:root {
  /* Bar charts use primary color from theme */
  --chart-bar-fill: hsl(var(--p));
  --chart-bar-stroke: hsl(var(--pf)); /* primary-focus */
  --chart-bar-hover-opacity: 0.8;

  /* Line charts */
  --chart-line-stroke: hsl(var(--p));
  --chart-line-stroke-width: 2px;
  --chart-area-fill: hsl(var(--p));
  --chart-area-opacity: 0.2;

  /* Axes and grids use neutral colors */
  --chart-axis-stroke: hsl(var(--n));
  --chart-grid-stroke: hsl(var(--bc) / 0.1);

  /* Text inherits base content color */
  --chart-title-color: hsl(var(--bc));
  --chart-label-color: hsl(var(--bc) / 0.7);
}

Usage in Partas.Solid Components:

[<SolidComponent>]
let ThemedBarChart(data: unit -> ChartDatum[]) =
    svg() {
        For'(each'=data(), children=(fun (d, i) ->
            rect() {
                fill "var(--chart-bar-fill)"
                stroke "var(--chart-bar-stroke)"
                class' "transition-opacity hover:opacity-80" // Tailwind utilities work too
                // D3 positioning...
            }
        ))

        g() {
            class' "axis"
            // Axis inherits --chart-axis-stroke from theme
        }
    }

Charts can also use Tailwind utility classes directly when appropriate, blending seamlessly with your Hugo theme’s styling approach.

Color Palette Strategies

Charts would leverage your Hugo theme’s color system. For example, DaisyUI themes provide semantic color categories that adapt automatically to theme changes:

1. Using Theme Color Variables:

module ColorPalettes =
    // DaisyUI semantic colors - automatically theme-aware
    let themeColors = [|
        "hsl(var(--p))"   // Primary
        "hsl(var(--s))"   // Secondary
        "hsl(var(--a))"   // Accent
        "hsl(var(--in))"  // Info
        "hsl(var(--su))"  // Success
        "hsl(var(--wa))"  // Warning
        "hsl(var(--er))"  // Error
    |]

    // Generate sequential palette from theme primary color
    let sequentialFromPrimary = [|
        "hsl(var(--p) / 0.2)"  // Lightest
        "hsl(var(--p) / 0.4)"
        "hsl(var(--p) / 0.6)"
        "hsl(var(--p) / 0.8)"
        "hsl(var(--p))"        // Full opacity
        "hsl(var(--pf))"       // Primary-focus (darker)
    |]

2. Using Tailwind Color Utilities (if your theme includes Tailwind):

    // Tailwind classes can be used directly on SVG elements
    let tailwindColorClasses = [|
        "fill-blue-500"
        "fill-green-500"
        "fill-red-500"
        "fill-orange-500"
        "fill-purple-500"
        "fill-teal-500"
    |]

Using Palettes in Charts:

[<SolidComponent>]
let MultiSeriesChart(data: unit -> ChartDatum[][]) =
    let colors = ColorPalettes.themeColors

    svg() {
        For'(each'=data(), children=(fun (series, seriesIdx) ->
            For'(each'=series, children=(fun (d, i) ->
                rect() {
                    fill colors.[seriesIdx % colors.Length]
                    // Automatically adapts when user switches theme
                }
            ))
        ))
    }

This approach ensures charts automatically adapt when users switch between light/dark themes or select different color schemesβ€”no chart-specific code changes required.

Typography and Text Rendering

Charts would inherit typography from your Hugo theme, ensuring consistent text rendering across your site:

[<SolidComponent>]
let ChartWithLabels(data: unit -> ChartDatum[], title: string) =
    svg() {
        // Chart title inherits theme typography
        text() {
            x 0
            y 20
            class' "text-2xl font-bold fill-base-content" // Tailwind + DaisyUI classes
            textContent title
        }

        // Axis labels
        For'(each'=data(), children=(fun (d, i) ->
            text() {
                class' "text-sm fill-base-content/70"
                textContent d.label
            }
        ))
    }

Alternatively, charts can reference CSS variables directly:

text() {
    fill "hsl(var(--bc))"           // Base content color from theme
    fontSize "var(--chart-title-size)" // Custom chart token
    fontFamily "inherit"             // Inherit from theme
    textContent title
}

Accessibility Patterns

The proposed charts would prioritize accessibility:

1. Semantic ARIA Roles:

[<SolidComponent>]
let AccessibleChart(data: unit -> ChartDatum[], title: string, description: string) =
    svg() {
        role "img"
        ariaLabel title
        ariaDescription description

        For'(each'=data(), children=(fun (d, i) ->
            rect() {
                role "graphics-symbol"
                ariaLabel $"{d.label}: {d.value}"
                tabIndex 0  // Keyboard accessible
            }
        ))
    }

2. Keyboard Navigation:

[<SolidComponent>]
let KeyboardNavigableChart(data: unit -> ChartDatum[]) =
    let focusedIndex, setFocusedIndex = createSignal(0)

    createEffect(fun () ->
        // Handle arrow keys for navigation
        let handler = fun (e: KeyboardEvent) ->
            match e.key with
            | "ArrowRight" ->
                setFocusedIndex(min (focusedIndex() + 1) (data().Length - 1))
                e.preventDefault()
            | "ArrowLeft" ->
                setFocusedIndex(max (focusedIndex() - 1) 0)
                e.preventDefault()
            | _ -> ()

        document.addEventListener("keydown", handler)
    )

    svg() {
        For'(each'=data(), children=(fun (d, i) ->
            rect() {
                class' (if i = focusedIndex() then "focused" else "")
                tabIndex (if i = focusedIndex() then 0 else -1)
            }
        ))
    }

3. High Contrast Mode Support:

/* Automatically adapts to Windows High Contrast mode */
@media (prefers-contrast: high) {
  :root {
    --chart-bar-fill: CanvasText;
    --chart-bar-stroke: CanvasText;
    --chart-grid-stroke: CanvasText;
  }
}

Animation and Transitions

Charts would use your theme’s animation timing, or DaisyUI’s built-in animation tokens:

/* DaisyUI provides animation tokens */
:root {
  --animation-btn: 0.25s;
  --animation-input: 0.2s;
}

/* Or define chart-specific timing that matches your theme */
.bar {
  transition:
    height var(--animation-btn) ease-in-out,
    fill 0.15s ease-in-out;
}

.bar:hover {
  opacity: 0.8;
}

D3 Transitions with Theme Timing:

// Use D3 transitions with timing that matches your theme
let updateBars (data: ChartDatum[]) =
    let bars = d3.selectAll(".bar").data(data)

    bars.transition()
        .duration(250)  // Matches typical theme animations
        .ease(d3.easeCubicInOut)
        .attr("height", fun d -> yScale(d.value))

Tailwind utilities can also handle transitions:

rect() {
    class' "transition-all duration-200 ease-in-out hover:opacity-80"
    // ...
}

Responsive Behavior

Charts would adapt to container size:

[<SolidComponent>]
let ResponsiveChart(data: unit -> ChartDatum[]) =
    let containerRef, setContainerRef = createSignal<HTMLElement option>(None)
    let dimensions, setDimensions = createSignal({ width = 600.0; height = 400.0 })

    createEffect(fun () ->
        match containerRef() with
        | Some container ->
            let observer = ResizeObserver(fun entries ->
                let rect = entries.[0].contentRect
                setDimensions({ width = rect.width; height = rect.height })
            )
            observer.observe(container)
        | None -> ()
    )

    div() {
        ref setContainerRef
        class' "chart-container"
        style "width: 100%; height: 100%"

        svg() {
            width (dimensions().width)
            height (dimensions().height)
            viewBox $"0 0 {dimensions().width} {dimensions().height}"

            // Chart content scales automatically
        }
    }

Theme Integration Pattern

Charts automatically inherit theming from your Hugo site via CSS custom properties. DaisyUI’s data-theme attribute handles theme switching at the document level:

<!-- Victor-generated HTML inherits theme from Hugo -->
<html data-theme="dark">
  <body>
    <article>
      <!-- Static content -->
      <div id="chart-mount"></div>
    </article>
  </body>
</html>

Charts automatically adapt to theme changes:

[<SolidComponent>]
let ThemedChart(data: unit -> ChartDatum[]) =
    svg() {
        rect() {
            // These CSS variables update automatically when data-theme changes
            fill "hsl(var(--p))"
            stroke "hsl(var(--pf))"
        }
    }

If you need to override theme colors for specific charts, use Solid’s context:

module ChartOverrides =
    type ChartColors = {
        barColor: string option
        lineColor: string option
    }

    let colorContext = createContext<ChartColors>()

[<SolidComponent>]
let CustomThemedChart(data: unit -> ChartDatum[]) =
    let overrides = useContext(ChartOverrides.colorContext)

    svg() {
        rect() {
            fill (overrides.barColor |> Option.defaultValue "hsl(var(--p))")
        }
    }

This keeps charts simple while allowing customization when needed.


Part V: Complete Implementation Example

Native Partas.Solid Line Chart

This example shows a complete, production-ready line chart component built as a native Partas.Solid component with D3 computation and FluentUI design alignment.

// Partas.Charts.D3/LineChart.fs
module Partas.Charts.D3.LineChart

open System
open Fable.Core
open Fable.Core.JsInterop
open Partas.Solid
open Browser.Types

// D3 bindings
[<ImportMember("d3-scale")>]
let scaleLinear() : obj = jsNative

[<ImportMember("d3-scale")>]
let scaleTime() : obj = jsNative

[<ImportMember("d3-shape")>]
let line() : obj = jsNative

[<ImportMember("d3-array")>]
let extent(data: 'T[], accessor: 'T -> 'U) : 'U[] = jsNative

[<ImportMember("d3-array")>]
let max(data: 'T[], accessor: 'T -> float) : float = jsNative

// Chart types
type LineChartDatum = {
    x: obj  // Date or number
    y: float
}

type LineChartConfig = {
    width: float
    height: float
    showPoints: bool
    showGrid: bool
    curveType: string
    margin: {| top: float; right: float; bottom: float; left: float |}
}

// Native Partas.Solid line chart component
[<SolidComponent>]
let LineChart(
    data: unit -> LineChartDatum[][],
    config: LineChartConfig,
    ?onPointClick: LineChartDatum * int -> unit
) =
    let chartWidth = config.width - config.margin.left - config.margin.right
    let chartHeight = config.height - config.margin.top - config.margin.bottom

    // Reactive D3 scales (recompute when data changes)
    let xScale() =
        let allPoints = data() |> Array.collect id
        if allPoints.Length = 0 then jsNative
        else
            let firstX = allPoints.[0].x
            let scale =
                match firstX with
                | :? DateTime -> scaleTime()
                | _ -> scaleLinear()

            let domain = extent(allPoints, fun d -> d.x)
            scale?domain(domain) |> ignore
            scale?range([| 0.0; chartWidth |]) |> ignore
            scale

    let yScale() =
        let allPoints = data() |> Array.collect id
        if allPoints.Length = 0 then jsNative
        else
            let scale = scaleLinear()
            let maxY = max(allPoints, fun d -> d.y)
            scale?domain([| 0.0; maxY |]) |> ignore
            scale?range([| chartHeight; 0.0 |]) |> ignore
            scale?nice() |> ignore
            scale

    let lineGenerator() =
        let generator = line()
        generator?x(fun (d: LineChartDatum) -> xScale() $ d.x) |> ignore
        generator?y(fun (d: LineChartDatum) -> yScale() $ d.y) |> ignore
        // Could add curve type here based on config
        generator

    svg() {
        width config.width
        height config.height
        class' "line-chart"
        role "img"
        ariaLabel $"Line chart with {data().Length} series"

        g() {
            transform $"translate({config.margin.left},{config.margin.top})"

            // Grid lines (if enabled)
            if config.showGrid then
                g() {
                    class' "grid"
                    // Grid implementation would go here
                }

            // Line paths for each series
            For'(each'=data(), children=(fun (series, seriesIdx) ->
                path() {
                    class' "line-series"
                    d (lineGenerator() $ series)
                    fill "none"
                    stroke $"var(--chart-line-stroke, #0078d4)"
                    strokeWidth 2
                }
            ))

            // Data points (if enabled)
            if config.showPoints then
                For'(each'=(data() |> Array.mapi (fun seriesIdx series ->
                    series |> Array.map (fun point -> (point, seriesIdx))
                ) |> Array.collect id), children=(fun ((point, seriesIdx), i) ->
                    circle() {
                        class' "data-point"
                        cx (xScale() $ point.x)
                        cy (yScale() $ point.y)
                        r 4
                        fill $"var(--chart-line-stroke, #0078d4)"
                        role "graphics-symbol"
                        ariaLabel $"{point.y}"
                        tabIndex 0

                        onClick (fun _ ->
                            onPointClick |> Option.iter (fun handler ->
                                handler (point, seriesIdx)
                            )
                        )
                    }
                ))

            // Axes would be rendered here using D3 axis generators
            // or custom SVG elements
        }
    }

// Usage example
[<SolidComponent>]
let TimeSeriesExample() =
    let seriesData, setSeriesData = createSignal [|
        [|
            { x = DateTime(2025, 1, 1) :> obj; y = 100.0 }
            { x = DateTime(2025, 2, 1) :> obj; y = 120.0 }
            { x = DateTime(2025, 3, 1) :> obj; y = 90.0 }
            { x = DateTime(2025, 4, 1) :> obj; y = 140.0 }
        |]
        [|
            { x = DateTime(2025, 1, 1) :> obj; y = 80.0 }
            { x = DateTime(2025, 2, 1) :> obj; y = 110.0 }
            { x = DateTime(2025, 3, 1) :> obj; y = 130.0 }
            { x = DateTime(2025, 4, 1) :> obj; y = 100.0 }
        |]
    |]

    let selectedPoint, setSelectedPoint = createSignal<(LineChartDatum * int) option>(None)

    let chartConfig = {
        width = 800.0
        height = 500.0
        showPoints = true
        showGrid = true
        curveType = "smooth"
        margin = {| top = 20.0; right = 20.0; bottom = 40.0; left = 50.0 |}
    }

    div() {
        class' "p-4"

        h2() {
            class' "text-2xl font-bold mb-4"
            text "Revenue Trends"
        }

        // Native Partas.Solid component
        LineChart(
            seriesData,
            chartConfig,
            onPointClick = (fun (point, seriesIdx) ->
                setSelectedPoint(Some (point, seriesIdx))
            )
        )

        // Selection display
        Show'(
            when'=(selectedPoint().IsSome),
            children=(fun () ->
                let point, series = selectedPoint().Value
                div() {
                    class' "mt-4 p-2 bg-blue-100 rounded"
                    text $"Series {series}, Value: {point.y}"
                }
            )
        )

        // Controls
        button() {
            class' "mt-4 px-4 py-2 bg-blue-500 text-white rounded"
            text "Add Random Month"
            onClick (fun _ ->
                let current = seriesData()
                let nextMonth = DateTime(2025, current.[0].Length + 1, 1)
                let newData = current |> Array.map (fun series ->
                    Array.append series [| {
                        x = nextMonth :> obj
                        y = Random().NextDouble() * 200.0
                    } |]
                )
                setSeriesData(newData)
            )
        }
    }

Part VI: Summary and Implementation Roadmap

Foundational Patterns Established

1. D3 as Pure Computation

  • D3’s scale, shape, and array modules are pure functions with no DOM dependencies
  • Charts use D3 for mathematics (coordinate transformations, path generation, statistics)
  • Rendering is handled by Solid.JS’s fine-grained reactivity

2. Native Partas.Solid Components

  • Erased types for zero-cost abstractions
  • Signal-driven reactivity for automatic recomputation
  • Builder syntax provides natural F# DSL for component configuration
  • Compiler visibility: Entire pipeline is F# AST visible to Fable

3. Following FluentUI Token Design

  • CSS custom properties for themeable charts (framework-agnostic)
  • Color palettes from established design research
  • Accessibility patterns (ARIA roles, keyboard navigation)
  • Responsive behavior using standard web APIs
  • Subtle animations following modern motion design principles

Strategic Advantages

For F# Developers

  • Functional-first approach: Pure functions, immutable data, explicit data flow
  • Type safety: F# type system validates chart configurations at compile time
  • Idiomatic syntax: Computation expressions and builder patterns feel natural
  • Zero runtime overhead: Erased types compile to optimal JavaScript
  • Compiler integration: Entire visualization pipeline visible to AST transformations

For SPEC Stack Optimization

  • Fine-grained reactivity: Solid.JS updates only changed DOM attributes
  • Compiler visibility: F# β†’ Fable β†’ Partas.Solid plugin can optimize entire flow
  • Signal-based scales: D3 computations only when dependencies change
  • Tree-shakeable: Import only needed D3 modules (d3-scale, d3-shape)
  • No framework boundaries: Everything compiles through the same pipeline

For Maintainability

  • Separation of concerns: D3 = math, Partas.Solid = reactivity, SVG = rendering
  • Pure functions: D3 scales and generators are testable in isolation
  • Modular design: Each chart type is independent F# module
  • Design system alignment: FluentUI tokens ensure visual consistency

Filling the Ecosystem Gap

Current State:

  • @fluentui/react-charting (npm): D3-powered charts for React only
  • Feliz ecosystem (GitHub): React-style bindings, misaligned with functional patterns
  • No F#-first charting: Existing solutions wrap JavaScript libraries

Proposed Contribution:

  • Partas.Charts.D3: Native F# chart components following Fabulous patterns
  • Functional D3 paradigm: Establish D3 as computation library, not DOM manipulator
  • Professional design: Accessible charts with modern visual language
  • SPEC-native: Optimized for the entire F# β†’ Fable β†’ Solid.JS pipeline

Implementation Roadmap

Phase 1: Core Foundation

  • D3 F# bindings (d3-scale, d3-shape, d3-array, d3-interpolate)
  • Victor CLI integration for theme token consumption
  • Color palette utilities leveraging theme colors (sequential, categorical, diverging)
  • Base chart utilities (margins, responsive sizing, accessibility helpers)

Phase 2: Essential Charts

  • Bar Chart: Vertical, horizontal, grouped, stacked
  • Line Chart: Single/multi-series, time-series, area variants
  • Scatter Plot: With trend lines, zoom/pan
  • Pie Chart: Pie, donut, with legends

Phase 3: Advanced Features

  • Interactive tooltips styled with theme tokens
  • Keyboard navigation support
  • High contrast mode support
  • Animation and transitions matching theme timing
  • Export utilities (SVG, PNG)

Phase 4: Documentation & Ecosystem

  • Interactive documentation site (built with Partas.Solid + Victor)
  • Accessibility audit (WCAG 2.1 AA compliance)
  • Performance benchmarks comparing to other charting libraries
  • NuGet package publication
  • Example dashboard applications showcasing Hugo theme integration

Integrating with Kobalte for Interactive UI

Kobalte provides headless, accessible UI primitives built natively for Solid.JS. These components integrate seamlessly with our chart architecture, maintaining compiler visibility throughout the stack.

Common UI Patterns with Charts:

open Kobalte

[<SolidComponent>]
let InteractiveChartDashboard() =
    let chartData, setChartData = createSignal [| ... |]
    let chartType, setChartType = createSignal "bar"
    let selectedPoint, setSelectedPoint = createSignal None

    div() {
        class' "dashboard"

        // Kobalte Select for chart type (native Solid.JS component)
        Kobalte.Select.Root() {
            value chartType
            onChange setChartType

            Kobalte.Select.Trigger() {
                class' "chart-type-selector"
                Kobalte.Select.Value() {
                    placeholder "Select Chart Type"
                }
                Kobalte.Select.Icon()
            }

            Kobalte.Select.Portal() {
                Kobalte.Select.Content() {
                    Kobalte.Select.Listbox() {
                        Kobalte.Select.Item(value = "bar") {
                            Kobalte.Select.ItemLabel() { "Bar Chart" }
                        }
                        Kobalte.Select.Item(value = "line") {
                            Kobalte.Select.ItemLabel() { "Line Chart" }
                        }
                    }
                }
            }
        }

        // Native Partas.Solid chart
        match chartType() with
        | "bar" -> BarChart(chartData, onBarClick = fun point ->
            setSelectedPoint(Some point))
        | "line" -> LineChart(chartData, onPointClick = fun (point, _) ->
            setSelectedPoint(Some point))
        | _ -> div() {}

        // Kobalte Popover for data point details
        Show'(when' = selectedPoint().IsSome, children = fun () ->
            let point = selectedPoint().Value

            Kobalte.Popover.Root() {
                Kobalte.Popover.Trigger() { "View Details" }
                Kobalte.Popover.Portal() {
                    Kobalte.Popover.Content() {
                        class' "popover-content"
                        Kobalte.Popover.Title() { "Data Point" }
                        Kobalte.Popover.Description() {
                            $"Value: {point.y}"
                        }
                    }
                }
            }
        )
    }

Styled with Hugo Theme Tokens (DaisyUI example):

/* Kobalte components inherit theme styling */
.chart-type-selector {
  background: hsl(var(--b1));
  border: 1px solid hsl(var(--bc) / 0.2);
  border-radius: var(--rounded-btn);
  padding: 0.5rem 1rem;
  font-size: 0.875rem;
}

.popover-content {
  background: hsl(var(--b1));
  border: 1px solid hsl(var(--bc) / 0.2);
  box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
  padding: 1rem;
}

/* Or use DaisyUI/Tailwind utility classes directly */
.chart-type-selector {
  @apply bg-base-100 border border-base-300 rounded-btn px-4 py-2 text-sm;
}

Advantages:

  • Full Compiler Visibility: Both charts and UI controls are native Solid.JS components
  • Consistent Architecture: No framework boundaries between charts and UI
  • Type Safety: F# types flow through the entire component tree
  • Accessibility: Kobalte provides WCAG-compliant primitives out of the box
  • Theme Coherence: CSS custom properties from your Hugo theme apply uniformly across all components

Conclusion: A Unified Architecture Proposal

This document has outlined a principled architecture for data visualization in the F# ecosystem that would combine:

  • Architectural patterns from Fabulous (MVU, declarative composition, functional UI)
  • Reactive foundation from Partas.Solid (signals, fine-grained updates)
  • Computational core from D3 (pure functional transformations)
  • UI primitives from Kobalte (accessible, headless components)
  • Design system integration via Victor CLI (Hugo theme transformation)
  • Architectural precedent from FluentUI.Charts (D3 as computation, not DOM manipulation)

The Proposed Stack

F# Source Code
    β”œβ”€ Partas.Solid (charts)
    β”œβ”€ Kobalte (UI controls)
    └─ D3 (pure computation)
         ↓
   Fable Compiler
  (full AST visibility)
         ↓
    Solid.JS Runtime
  (fine-grained reactivity)
         ↓
    Rendered DOM
  (styled with Hugo theme tokens via Victor)

Every layer would be compiler-visible F# code: this would mean no foreign components, no compilation boundaries, no framework impedance mismatches.

What Would Make This Architecture Compelling

  1. Unified Reactivity: Partas.Solid signals would drive both charts and UI controls through the same reactive graph
  2. Pure Functional Core: D3 would provide mathematical transformations without DOM coupling (insight from FluentUI.Charts)
  3. Native Integration: Kobalte components are Solid.JS natives, not foreign wrappers
  4. Design System Coherence: Your Hugo theme’s design tokens (DaisyUI, Tailwind, etc.) would style everything uniformly via CSS custom properties
  5. Compiler Optimization: F# β†’ Fable would see the entire pipeline, enabling AST-level transformations
  6. Static Site Integration: Victor CLI would transform Hugo templates into Partas.Solid components, creating seamless static-to-dynamic experiences

For F# Developers

This proposal represents more than just a charting library. It demonstrates what becomes possible through principled functional design:

  • No JavaScript interop complexity - Everything would compile from F# through one pipeline
  • No virtual DOM overhead - Solid’s fine-grained reactivity would update only what changed
  • No framework boundaries - Charts, UI controls, and application code would share the same reactive primitives
  • No styling fragmentation - Your Hugo theme’s design system would govern the entire visual language
  • No static vs. dynamic divide - Victor CLI would bridge Hugo templates and Partas.Solid components seamlessly

The result would be a charting library that feels native to F#, performs exceptionally with rich datasets, integrates seamlessly with the SPEC stack, and inherits professional, accessible styling from your existing Hugo themeβ€”no additional design system required.

Author
Houston Haynes
category
Software

We want to hear from you!

Contact Us