Skip to content

Advanced Customization

Learn how to create sophisticated hockey visualizations using the customRender hook and advanced configuration options.

What is customRender?

The customRender hook gives you direct access to D3 selections and calculated position data, allowing you to add custom elements, interactions, and behaviors beyond the standard configuration.

typescript
customRender: (selection, dimensions, context) => {
  // selection: D3 selection of event elements
  // dimensions: { scale, padding, width, height }
  // context: { position, data, index, container, layer }

  const { position, data, index, container } = context;
  // position: { svgX, svgY, dataX, dataY }
  // data: your event data
  // index: element index
  // container: parent SVG group for adding siblings
};

When to Use

Use customRender when you need to:

  • Add labels, arrows, or decorative elements
  • Create composite visualizations
  • Add custom interactions beyond tooltips
  • Build conditional visualizations
  • Connect events with lines or paths

Don't use customRender for:

  • Simple color/size changes (use config options)
  • Adding basic attributes (use customAttributes)
  • Entirely new layer types (extend BaseLayer)

Player Name Labels

Add text labels showing player names next to each event.

typescript
import { Rink } from "d3-hockey";
import * as d3 from "d3";

const shots = [
  { coordinates: { x: 48, y: 15 }, player: "Ovechkin", type: "GOAL" },
  { coordinates: { x: 73, y: -10 }, player: "Backstrom", type: "SHOT" },
  { coordinates: { x: 70, y: 12 }, player: "Wilson", type: "SHOT" },
];

new Rink("#container").render().addEvents(shots, {
  id: "labeled",
  radius: 5,
  tooltip: (d) => `<strong>${d.player}</strong><br/>
            Event: ${d.type}<br/>
            Location (${d.coordinates.x}, ${d.coordinates.y})`,
  customRender: (selection, dimensions, context) => {
    const { position, data, container } = context;
    d3.select(container)
      .append("text")
      .attr("x", position.svgX + 10)
      .attr("y", position.svgY - 10)
      .attr("font-size", "11px")
      .attr("font-weight", "bold")
      .attr("fill", "#000")
      .text(data.player);
  },
});

Speed Rings

Visualize shot speed with rings around each event.

typescript
const shotsWithSpeed = [
  { coordinates: { x: 45, y: 3 }, player: "Weber", speed: 108 },
  { coordinates: { x: 55, y: -8 }, player: "Subban", speed: 95 },
  { coordinates: { x: 70, y: 12 }, player: "Gallagher", speed: 82 },
];

new Rink("#container").render().addEvents(shotsWithSpeed, {
  id: "speed",
  radius: 5,
  tooltip: (d) => `<strong>${d.player}</strong><br/>
            Speed: ${d.speed} mph<br/>
            Location: (${d.coordinates.x}, ${d.coordinates.y})`,
  customRender: (selection, dimensions, context) => {
    const { position, data, container } = context;
    const ringRadius = ((data.speed - 60) / 60) * 20;
    d3.select(container)
      .append("circle")
      .attr("cx", position.svgX)
      .attr("cy", position.svgY)
      .attr("r", ringRadius)
      .attr("fill", "none")
      .attr("stroke", "#2196F5")
      .attr("stroke-width", 1.5)
      .attr("stroke-dasharray", "3,3")
      .attr("opacity", 0.6);
  },
});

Pulsating Goals

Make goals stand out with pulsating animations.

typescript
const events = [
  { coordinates: { x: 85, y: 5 }, type: "GOAL", player: "Matthews" },
  { coordinates: { x: 75, y: -8 }, type: "SHOT", player: "Marner" },
  { coordinates: { x: 70, y: 12 }, type: "SHOT", player: "Nylander" },
];

new Rink("#container").render().addEvents(events, {
  id: "pulsating-goals",
  radius: 6,
  tooltip: (d) => `<strong>${d.player}</strong><br/>
    Event: ${d.type}<br/>
    Location: (${d.coordinates.x}, ${d.coordinates.y})`,
  customRender: (selection, dimensions, context) => {
    if (context.data.type !== "GOAL") return;
    function pulse() {
      selection
        .transition()
        .duration(1000)
        .attr("opacity", 0.3)
        .transition()
        .duration(1000)
        .attr("opacity", 1)
        .on("end", pulse);
    }

    const animationDelay = context.layer.config.animate
      ? context.layer.config.animationDuration + 50
      : 0;

    setTimeout(pulse, animationDelay);
  },
});

Shot Sequence

Connect shots with lines to show passing or shot sequences.

typescript
const sequence = [
  { coordinates: { x: 50, y: 5 }, player: "A", sequence: 1 },
  { coordinates: { x: 65, y: 10 }, player: "B", sequence: 2 },
  { coordinates: { x: 80, y: 5 }, player: "C", sequence: 3, type: "GOAL" },
];

// Store positions for connecting lines
const positions = [];

new Rink("#container").render().addEvents(sequence, {
  id: "sequence",
  color: (d) => (d.type === "GOAL" ? "#00FF00" : "#FF4C00"),
  radius: 5,
  customRender: (selection, dimensions, context) => {
    const { position, data, container, index } = context;

    // Collect positions
    positions.push({
      x: position.svgX,
      y: position.svgY,
      sequence: data.sequence,
    });

    // Draw connecting lines after last element
    if (index === sequence.length - 1) {
      const parent = d3.select(container);
      positions.sort((a, b) => a.sequence - b.sequence);

      for (let i = 0; i < positions.length - 1; i++) {
        const start = positions[i];
        const end = positions[i + 1];

        parent
          .insert("line", ":first-child")
          .attr("class", "sequence-line")
          .attr("x1", start.x)
          .attr("y1", start.y)
          .attr("x2", end.x)
          .attr("y2", end.y)
          .attr("stroke", "#2196F3")
          .attr("stroke-width", 2)
          .attr("stroke-dasharray", "5,5")
          .attr("opacity", 0.6);
      }
    }
  },
});

Click Interactions

Add custom click handlers to events.

typescript
const shots = [
  {
    coordinates: { x: 85, y: 5 },
    player: "Kane",
    shotType: "Wrist Shot",
    speed: 95,
  },
  {
    coordinates: { x: 75, y: -8 },
    player: "Toews",
    shotType: "Slap Shot",
    speed: 102,
  },
];

new Rink("#container").render().addEvents(shots, {
  id: "clickable",
  color: "#c8102e",
  radius: 6,
  customRender: (selection, dimensions, context) => {
    const { data, layer } = context;

    selection
      .on("click", function (event) {
        event.stopPropagation();

        // Highlight clicked element
        d3.selectAll("path.event-symbol").attr("stroke-width", 1);
        d3.select(this).attr("stroke-width", 4).attr("stroke", "#000");

        // Update details panel
        d3.select("#shot-details").html(`
          <h4 style="margin: 0 0 0.5rem 0;">${data.player}</h4>
          <p style="margin: 0;">
            <strong>Shot Type:</strong> ${data.shotType}<br/>
            <strong>Speed:</strong> ${data.speed} MPH<br/>
            <strong>Location:</strong> (${data.coordinates.x}, ${data.coordinates.y})
          </p>
        `);
      })
      .on("mouseenter", function () {
        d3.select(this).attr("opacity", 0.7);
      })
      .on("mouseleave", function () {
        d3.select(this).attr("opacity", 1);
      });
  },
});

Conditional Decorations

Add different decorations based on event properties.

typescript
const events = [
  { coordinates: { x: 85, y: 5 }, type: "GOAL" },
  { coordinates: { x: 75, y: -8 }, type: "SHOT" },
  { coordinates: { x: 60, y: 12 }, type: "MISSED" },
];

new Rink("#container").render().addEvents(events, {
  id: "conditional",
  color: (d) => {
    if (d.type === "GOAL") return "#00FF00";
    if (d.type === "MISSED") return "#FF6600";
    return "#0088FF";
  },
  radius: 5,
  customRender: (selection, dimensions, context) => {
    const { position, data, container } = context;
    const parent = d3.select(container);

    if (data.type === "GOAL") {
      // Add star burst for goals
      for (let i = 0; i < 8; i++) {
        const angle = i * 45 * (Math.PI / 180);
        const length = 15;

        parent
          .append("line")
          .attr("class", "goal-burst")
          .attr("x1", position.svgX)
          .attr("y1", position.svgY)
          .attr("x2", position.svgX + Math.cos(angle) * length)
          .attr("y2", position.svgY + Math.sin(angle) * length)
          .attr("stroke", "#FFD700")
          .attr("stroke-width", 2)
          .attr("opacity", 0.7);
      }
    } else if (data.type === "MISSED") {
      // Add X mark for misses
      parent
        .append("path")
        .attr("class", "miss-mark")
        .attr(
          "d",
          `M ${position.svgX - 8},${position.svgY - 8} L ${position.svgX + 8},${position.svgY + 8} M ${position.svgX - 8},${position.svgY + 8} L ${position.svgX + 8},${position.svgY - 8}`,
        )
        .attr("stroke", "#F44336")
        .attr("stroke-width", 2)
        .attr("opacity", 0.5);
    }
  },
});

Heat Trails

Create heat trail effects behind events.

typescript
const shots = [
  { coordinates: { x: 85, y: 5 }, danger: 0.8 },
  { coordinates: { x: 75, y: -10 }, danger: 0.5 },
  { coordinates: { x: 60, y: 12 }, danger: 0.3 },
];

new Rink("#container").render().addEvents(shots, {
  id: "trails",
  color: "#FF4C00",
  radius: 5,
  customRender: (selection, dimensions, context) => {
    const { position, data, container } = context;
    const parent = d3.select(container);

    // Create gradient rings based on danger
    const numRings = 3;
    for (let i = 0; i < numRings; i++) {
      parent
        .insert("circle", ":first-child")
        .attr("cx", position.svgX)
        .attr("cy", position.svgY)
        .attr("r", 5 + i * 8)
        .attr("fill", "#FF4C00")
        .attr("opacity", data.danger * 0.4 - i * 0.1)
        .attr("class", "heat-trail");
    }
  },
});

Tips & Best Practices

Context Object

The context parameter provides everything you need:

typescript
customRender: (selection, dimensions, context) => {
  const { position, data, index, container, layer } = context;

  // position.svgX/svgY - Calculated SVG pixel coordinates
  // position.dataX/dataY - Original NHL coordinates
  // data - Your event data (fully typed)
  // index - Element index in the dataset
  // container - Parent SVG group for adding siblings
  // layer - Reference to EventLayer instance
};

Adding Sibling Elements

Use the container to add elements alongside the symbol:

typescript
customRender: (selection, dimensions, context) => {
  d3.select(context.container)
    .append("text")
    .attr("x", context.position.svgX)
    .attr("y", context.position.svgY)
    .text(context.data.player);
};

Accessing Layer Configuration

The layer property gives you access to all layer config:

typescript
customRender: (selection, dimensions, context) => {
  // Access layer configuration
  const layerColor = context.layer.config.color;
  const layerRadius = context.layer.config.radius;

  // Use dimensions for scaling
  const scale = dimensions.scale;
};

Performance Considerations

customRender is called once per element on each render:

  • Keep operations efficient
  • Avoid expensive calculations
  • Filter early: if (context.data.type !== "GOAL") return;
  • Consider debouncing for large datasets

See Also