DEV Community

Cover image for Building High-Performance Reactive SVG Visualizers in Next.js 15 Without Heavy Charting Libraries
miad
miad

Posted on Originally published at hvaclogic.org

Building High-Performance Reactive SVG Visualizers in Next.js 15 Without Heavy Charting Libraries

When engineering browser-based simulation tools for mechanical engineering and building science, UI responsiveness is non-negotiable. HVAC design calculations (such as Darcy-Weisbach friction gradients, psychrometric moist-air state points, and multi-zone heat loss distributions) require continuous visual feedback as engineers manipulate sliders and numeric inputs.

In early iterations of our open-access engineering suite at HVACLogic, our instinct was to drop in standard React charting libraries (Recharts, Chart.js, or D3 wrappers). The resulting architecture worked, but it introduced severe engineering trade-offs:

  1. Bundle Overhead: Adding Recharts and its associated dependencies introduced over 280kB of minified JavaScript to the initial bundle.
  2. Hydration Jank & INP Penalties: Heavy virtual DOM reconciliations across canvas/SVG wrapper nodes degraded Core Web Vitals, causing Interaction to Next Paint (INP) to climb above 120ms during continuous slider drags.
  3. Domain Constraint Mismatch: Generic charting libraries expect standard Cartesian bar or line series. They fight you when you need exact physical aspect ratios, fluid velocity vectors, or thermodynamic pressure gradients.

In this article, we break down how we architected 100% reactive, zero-dependency SVG visualizers in Next.js 15 and React 19, demonstrating real code patterns from production.


The Architecture: Pure Mathematical State to SVG Mapping

The core philosophy is simple: Math functions produce geometric coordinates; React merely binds them to native SVG elements.

Instead of importing a monolithic charting library, we write deterministic projection functions that convert numerical engineering outputs directly into SVG path strings (d="M ... A ..." or <circle /> coordinates).

[User Input State] 
       │
       ▼
[Deterministic Physics Solver (Pure TS)] 
       │
       ▼
[SVG Coordinate Normalization] 
       │
       ▼
[Native Next.js 15 SVG Component (Zero Runtime Deps)]
Enter fullscreen mode Exit fullscreen mode

Because native SVG elements are standard DOM nodes, the browser hardware-accelerates rendering via the GPU, completely eliminating third-party runtime overhead.


Example 1: Real-Time Trigonometric Donut Visualizer Without D3

Consider a multi-component building envelope heat loss breakdown (walls, fenestration, ceiling, infiltration). Rather than pulling in D3-shape or Pie charts to render a distribution donut, we compute the SVG arc paths using elementary trigonometry:

// src/components/calculator/visualizers/HeatLossDonutVisualizer.tsx
"use client";

import React from "react";

export interface LoadBreakdownItem {
  label: string;
  btu: number;
  percentage: number;
  color: string;
}

export function HeatLossDonutVisualizer({ breakdown }: { breakdown: LoadBreakdownItem[] }) {
  let cumulativePercentage = 0;

  const segments = breakdown.map((item) => {
    const startAngle = (cumulativePercentage / 100) * 360;
    cumulativePercentage += item.percentage;
    const endAngle = (cumulativePercentage / 100) * 360;

    // Convert polar degrees to Cartesian radians (-90deg offset for 12 o'clock top start)
    const startRad = ((startAngle - 90) * Math.PI) / 180;
    const endRad = ((endAngle - 90) * Math.PI) / 180;

    const r = 70;
    const cx = 100;
    const cy = 100;

    const x1 = cx + r * Math.cos(startRad);
    const y1 = cy + r * Math.sin(startRad);
    const x2 = cx + r * Math.cos(endRad);
    const y2 = cy + r * Math.sin(endRad);

    const largeArcFlag = item.percentage > 50 ? 1 : 0;
    const d = `M ${x1} ${y1} A ${r} ${r} 0 ${largeArcFlag} 1 ${x2} ${y2}`;

    return { ...item, d };
  });

  return (
    <div className="donut-container">
      <svg viewBox="0 0 200 200" width="100%" height="100%" aria-label="Heat Load Distribution Donut">
        <circle cx="100" cy="100" r="70" fill="none" stroke="var(--surface)" strokeWidth="26" />
        {segments.map((seg) => (
          <path
            key={seg.label}
            d={seg.d}
            fill="none"
            stroke={seg.color}
            strokeWidth="24"
            strokeLinecap="round"
          />
        ))}
      </svg>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This entire component compiles down to less than 1.5kB of code, executes at 60 FPS on low-end mobile devices, and renders pixel-perfect vector curves at any screen resolution. You can inspect this live in our Heat Loss Calculator.


Example 2: Dynamic Aerodynamic Pressure Decay Gradients

In our Duct Static Pressure Drop Calculator, mechanical contractors must visualize Available Static Pressure (ASP) decay along the Total Effective Length (TEL) of an air distribution run per ACCA Manual D:

ASP = TESP - ΔP(coil) - ΔP(filter) - ΔP(supply devices)
Enter fullscreen mode Exit fullscreen mode

To visualize this dynamic decay curve without Canvas or WebGL, we project normalized static pressure values directly into SVG viewBox coordinate units:

// Excerpt from src/components/calculator/visualizers/DuctFrictionVisualizer.tsx
export function DuctFrictionVisualizer({ output }: DuctFrictionVisualizerProps) {
  // SVG coordinates: viewBox 0 0 460 165
  const startY = 40;
  // Calculate relative pressure drop slope
  const aspDropY = 20 + Math.max(0, (0.50 - output.availableStaticPressureAspInWg) * 50);

  return (
    <svg viewBox="0 0 460 165" style={{ width: "100%", height: "100%" }}>
      {/* Background Track */}
      <rect x="30" y="15" width="400" height="40" fill="rgba(15, 23, 42, 0.6)" rx="4" />

      {/* Blower TESP Origin */}
      <circle cx="50" cy={startY} r="4" fill="#00d2ff" />

      {/* Pressure Decay Gradient Path */}
      <path
        d={`M 50 ${startY} L 140 ${startY} L 170 32 L 280 32 L 410 ${aspDropY}`}
        fill="none"
        stroke={statusColor}
        strokeWidth="2.5"
      />

      {/* Available Static Budget Point */}
      <circle cx="410" cy={aspDropY} r="4" fill={statusColor} />
    </svg>
  );
}
Enter fullscreen mode Exit fullscreen mode

Performance Benchmarks: External Charting vs. Pure SVG

We benchmarked a continuous slider input dispatching 60 state changes per second on a simulated mobile CPU (Chrome DevTools 4x CPU Throttling):

Metric Recharts / Chart.js Pure Reactive SVG (Next.js 15) Delta
Initial JS Bundle Size 284 kB minified 0 kB (Native SVG) -100%
Interaction to Next Paint (INP) 114 ms 16 ms -86%
FPS During Continuous Slider Drag 34 FPS 60 FPS +76%
Cumulative Layout Shift (CLS) 0.042 (re-layout) 0.000 Zero CLS

By relying on native SVG geometric primitives (viewBox, strokeLinecap="round", M ... A ...), the browser avoids layout thrashing and renders transitions at the native refresh rate.


Unit Testing SVG Invariants with Vitest

A common objection to hand-rolled SVG visualizers is maintainability. How do you verify that dynamic path strings do not generate NaN coordinates or broken arcs when edge-case numbers are entered?

We run deterministic unit tests using Vitest to assert SVG path geometry:

// tests/visualizers/heat-loss-donut.test.ts
import { describe, it, expect } from "vitest";

describe("HeatLossDonutVisualizer Trigonometric Invariants", () => {
  it("should generate valid coordinate numbers without NaN for fractional loads", () => {
    const breakdown = [
      { label: "Walls", btu: 12000, percentage: 40.5, color: "#00d2ff" },
      { label: "Windows", btu: 8000, percentage: 27.0, color: "#3b82f6" },
      { label: "Ceiling", btu: 5000, percentage: 16.9, color: "#10b981" },
      { label: "Infiltration", btu: 4600, percentage: 15.6, color: "#f59e0b" },
    ];

    let cumulativePercentage = 0;
    breakdown.forEach((item) => {
      const startAngle = (cumulativePercentage / 100) * 360;
      cumulativePercentage += item.percentage;
      const endAngle = (cumulativePercentage / 100) * 360;

      const startRad = ((startAngle - 90) * Math.PI) / 180;
      const endRad = ((endAngle - 90) * Math.PI) / 180;

      const x1 = 100 + 70 * Math.cos(startRad);
      const y1 = 100 + 70 * Math.sin(startRad);
      const x2 = 100 + 70 * Math.cos(endRad);
      const y2 = 100 + 70 * Math.sin(endRad);

      expect(Number.isNaN(x1)).toBe(false);
      expect(Number.isNaN(y1)).toBe(false);
      expect(Number.isNaN(x2)).toBe(false);
      expect(Number.isNaN(y2)).toBe(false);
    });

    expect(Math.round(cumulativePercentage)).toBe(100);
  });
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

Third-party charting libraries are fantastic for business dashboards with hundreds of data points and multi-axis toggles. But when building high-performance technical calculators, interactive tools, and domain-specific engineering applications:

  • Drop the 250kB bundle weight.
  • Embrace native SVG mathematical projections.
  • Achieve sub-20ms INP and silky 60 FPS interactions.

You can explore all 21 open-access, zero-dependency engineering visualizers live in production across the HVACLogic Engineering Suite.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.