Detailed Guide: Creating Power BI Custom Visuals with TypeScript and D3.js

Complete Guide: Developing & Deploying Power BI Custom Visuals with TypeScript & D3.js

Complete Guide: Developing & Deploying Power BI Custom Visuals with TypeScript & D3.js

This comprehensive guide covers the entire process of creating, developing, packaging, and importing a Power BI custom visual using TypeScript and D3.js. We'll also incorporate solutions to common issues encountered during development, ensuring a smoother learning experience.

Prerequisites

Before you begin, ensure you have the following tools installed on your system:

  • Node.js (LTS version): This includes npm (Node Package Manager), which is essential for managing project dependencies and the Power BI tools. Download from nodejs.org.
  • Visual Studio Code (VS Code): A highly recommended code editor with excellent TypeScript support. Download from code.visualstudio.com.
  • Power BI Desktop: To test and use your custom visual. Download from powerbi.microsoft.com/desktop.

Step 1: Install Power BI Custom Visuals Tools

The Power BI visuals tools provide the command-line interface (pbiviz) necessary to create, develop, and package your custom visuals.

  • Open your terminal or command prompt.
  • Run the following command to install the tools globally:
    npm install -g powerbi-visuals-tools
    
    This command installs the pbiviz command, which you'll use throughout the development process.

Step 2: Create a New Custom Visual Project

Create the basic project structure for your custom visual.

  • Navigate to the directory where you want to create your project in your terminal.
  • Run the pbiviz new command, followed by your desired project name:
    pbiviz new MyD3VisualProject
    
    Replace MyD3VisualProject with a name of your choice. This command will create a new folder with that name and populate it with the boilerplate files for a custom visual.

Step 3: Explore the Project Structure and Configure pbiviz.json

Navigate into your newly created project folder (cd MyD3VisualProject). Here's a brief overview of the key files and folders:

  • pbiviz.json: Visual metadata (name, version, author). You must configure this file.
  • src/visual.ts: Main TypeScript file for core visual logic (rendering, updating).
  • src/settings.ts: Defines properties for the Power BI format pane.
  • capabilities.json: Describes data roles and properties your visual supports.
  • node_modules/: Project dependencies.
  • dist/: Compiled JavaScript and assets after building.

Configure pbiviz.json

The pbiviz.json file contains essential metadata about your visual. When packaging, the CLI checks for required fields. You need to fill in the author details, description, supportUrl, and crucially, the apiVersion.

Open pbiviz.json and update it to include the following details. Pay close attention to the apiVersion field:

{
    "visual": {
        "name": "MyD3VisualProject",
        "displayName": "My D3 Visual",
        "guid": "MyD3VisualProjectXXXXXXX", // REPLACE with your actual GUID
        "visualVersion": "1.0.0.0",
        "description": "A custom Power BI visual built with D3.js for interactive data visualization.",
        "supportUrl": "https://www.example.com/support", // REPLACE with your support URL
        "gitHubUrl": "https://github.com/yourusername/MyD3VisualProject", // REPLACE with your GitHub URL
        "images": {
            "icon": "assets/icon.png",
            "thumbnail": "assets/thumbnail.png",
            "screenshots": []
        },
        "version": "1.0.0.0"
    },
    "author": {
        "name": "Your Name", // REPLACE with your name
        "email": "your.email@example.com" // REPLACE with your email
    },
    "assets": {
        "icon": "assets/icon.png",
        "css": "style/visual.less",
        "template": "src/visual.ts"
    },
    "externalJS": [],
    "style": "style/visual.less",
    "capabilities": "capabilities.json",
    "dependencies": null,
    "stringResources": [],
    "apiVersion": "5.3.0", // IMPORTANT: Add or ensure this line exists and matches your installed API version
    "version": "1.0.0.0"
}

Important:

  • Replace "MyD3VisualProjectXXXXXXX" with the actual GUID generated for your project (it's usually already there when you run pbiviz new).
  • Update "Your Name", "your.email@example.com", "https://www.example.com/support", and "https://github.com/yourusername/MyD3VisualProject" with your actual information.
  • The description field should briefly explain what your visual does.
  • The supportUrl should be a valid URL where users can find support for your visual.
  • apiVersion: Make sure this matches the powerbi-visuals-api version installed in your node_modules (e.g., "5.3.0" as seen in your package.json or installation logs). This is critical for compatibility.

Step 4: Integrate D3.js

The pbiviz new command typically installs D3.js and its type definitions by default. If not, or if you need to update, follow these steps:

  • In your project directory, install D3.js and its type definitions:
    npm install d3 @types/d3
    
  • In your src/visual.ts file, ensure you have the D3 import at the top:
    import * as d3 from 'd3';
    
    This line makes all D3.js functionalities available within your visual.ts file.

Step 5: Update src/visual.ts (Crucial Imports & Logic)

The boilerplate src/visual.ts from pbiviz new uses a specific import pattern for Power BI API types. Ensure your src/visual.ts matches this structure, especially if you've copied code from other examples. This example provides a basic bar chart structure with D3.js.

Correct src/visual.ts Structure:

import * as d3 from 'd3'; // D3.js import
import powerbi from "powerbi-visuals-api"; // Import the powerbi namespace
import { FormattingSettingsService } from "powerbi-visuals-utils-formattingmodel"; // Import FormattingSettingsService

// Use type aliases from the powerbi namespace for Power BI API types
import VisualConstructorOptions = powerbi.extensibility.visual.VisualConstructorOptions;
import VisualUpdateOptions = powerbi.extensibility.visual.VisualUpdateOptions;
import IVisual = powerbi.extensibility.IVisual;
import IVisualHost = powerbi.extensibility.IVisualHost; // Import IVisualHost for tooltips, etc.
import DataView = powerbi.DataView; // Import DataView for data access


import { VisualFormattingSettingsModel } from "./settings"; // Import your settings model

import "./../style/visual.less"; // Import visual styling (if using LESS)

// Define a data point interface for clarity and type safety
interface BarChartDataPoint {
    category: string;
    value: number;
    color: string;
}

export class Visual implements IVisual {
    // Correctly type host as an HTMLElement selection (the root container provided by Power BI)
    private host: d3.Selection<HTMLElement, any, any, any>;
    private svg: d3.Selection<SVGElement, any, any, any>; // SVG element for drawing
    private xAxisGroup: d3.Selection<SVGGElement, any, any, any>;
    private yAxisGroup: d3.Selection<SVGGElement, any, any, any>;
    private barsGroup: d3.Selection<SVGGElement, any, any, any>;
    private tooltip: d3.Selection<HTMLDivElement, any, any, any>; // For the tooltip element

    private formattingSettings: VisualFormattingSettingsModel;
    private formattingSettingsService: FormattingSettingsService;
    private visualHost: IVisualHost; // To access Power BI host services like tooltips, selection, etc.

    private margin = { top: 20, right: 20, bottom: 80, left: 60 }; // Increased bottom margin for category labels

    // Constructor: Called once when the visual is initialized
    constructor(options: VisualConstructorOptions) {
        this.visualHost = options.host; // Initialize visualHost
        this.formattingSettingsService = new FormattingSettingsService();

        // Select the host element (which is an HTMLElement) provided by Power BI
        this.host = d3.select(options.element);

        // Append the main SVG element to the host container as the drawing canvas
        this.svg = this.host.append('svg')
                           .attr('width', '100%')
                           .attr('height', '100%')
                           .classed('bar-chart-visual', true); // Add a CSS class for styling

        // Append groups for axes and bars to organize SVG elements and apply transformations
        this.barsGroup = this.svg.append('g').classed('bars', true);
        this.xAxisGroup = this.svg.append('g').classed('x-axis', true);
        this.yAxisGroup = this.svg.append('g').classed('y-axis', true);

        // Initialize a hidden tooltip div in the body (will be styled via CSS)
        this.tooltip = d3.select("body").append("div")
            .attr("class", "tooltip")
            .style("position", "absolute")
            .style("background-color", "white")
            .style("border", "1px solid #ccc")
            .style("padding", "8px")
            .style("border-radius", "4px")
            .style("pointer-events", "none") // Ensures tooltip doesn't block mouse events on elements below it
            .style("opacity", 0); // Start hidden
    }

    // Update method: Called whenever data or visual properties change
    public update(options: VisualUpdateOptions) {
        // Populate formatting settings model from dataView (for properties defined in settings.ts)
        this.formattingSettings = this.formattingSettingsService.populateFormattingSettingsModel(VisualFormattingSettingsModel, options.dataViews[0]);

        // Get current viewport dimensions
        const viewportWidth = options.viewport.width;
        const viewportHeight = options.viewport.height;

        // Calculate inner dimensions for the chart area, respecting margins
        const width = viewportWidth - this.margin.left - this.margin.right;
        const height = viewportHeight - this.margin.top - this.margin.bottom;

        // Update SVG dimensions to match the viewport
        this.svg.attr('width', viewportWidth).attr('height', viewportHeight);

        // Transform bar and axis groups to account for margins, positioning them correctly within the SVG
        this.barsGroup.attr('transform', `translate(${this.margin.left}, ${this.margin.top})`);
        this.xAxisGroup.attr('transform', `translate(${this.margin.left}, ${height + this.margin.top})`);
        this.yAxisGroup.attr('transform', `translate(${this.margin.left}, ${this.margin.top})`);

        // Data validation: Check if data is present and in the expected categorical format
        if (!options.dataViews || !options.dataViews[0] || !options.dataViews[0].categorical) {
            // If no valid data, clear all existing elements from the chart groups
            this.barsGroup.selectAll('*').remove();
            this.xAxisGroup.selectAll('*').remove();
            this.yAxisGroup.selectAll('*').remove();
            return; // Exit the update method
        }

        // Extract categorical data from Power BI's dataView
        const dataView: DataView = options.dataViews[0];
        const categoricalData = dataView.categorical;

        // Cast values to specific types for better type safety
        const categories = categoricalData.categories[0].values as string[];
        const values = categoricalData.values[0].values as number[];

        // Map the raw Power BI data into a more D3-friendly array of objects
        const data: BarChartDataPoint[] = categories.map((category, i) => ({
            category: category,
            value: values[i] || 0, // Ensure value is a number, default to 0 if undefined/null
            color: this.formattingSettings.general.fill.value.value // Use the color from settings.ts
        }));

        // Filter out any data points where category or value might be invalid (e.g., null, undefined, NaN)
        const validData = data.filter(d => d.category !== null && d.category !== undefined && d.value !== null && d.value !== undefined && !isNaN(d.value));

        if (validData.length === 0) {
            // If no valid data after filtering, clear all elements
            this.barsGroup.selectAll('*').remove();
            this.xAxisGroup.selectAll('*').remove();
            this.yAxisGroup.selectAll('*').remove();
            return;
        }

        // Define X-scale (Band scale for categories)
        const xScale = d3.scaleBand()
                         .range([0, width])
                         .padding(0.1)
                         .domain(validData.map(d => d.category));

        // Define Y-scale (Linear scale for values)
        const maxVal = d3.max(validData, d => d.value);
        const yScale = d3.scaleLinear()
                         .range([height, 0]) // SVG y-axis is inverted (0 at top, height at bottom)
                         .domain([0, maxVal ? maxVal * 1.1 : 100]); // Add a little padding to the top of the Y-axis

        // D3 Data Join: Select existing bars, bind new data
        const bars = this.barsGroup.selectAll('rect')
                                   .data(validData);

        // EXIT selection: Remove elements that no longer have corresponding data
        bars.exit().remove();

        // ENTER selection: Create new elements for new data points
        // UPDATE selection: Apply attributes to both new and existing elements
        bars.enter()
            .append('rect')
            .merge(bars as any) // Merge enter and update selections for common attributes
            .attr('x', d => xScale(d.category)!) // X position based on category
            .attr('y', d => yScale(d.value)) // Y position based on value (top of bar)
            .attr('width', xScale.bandwidth()) // Width of bar from scaleBand
            .attr('height', d => height - yScale(d.value)) // Height of bar
            .attr('fill', d => d.color) // Bar color from data point (which gets it from settings)
            .on("mouseover", (event, d) => { // Tooltip show on mouseover
                this.tooltip.style("opacity", 1);
                // Clear previous content and append text nodes for security and new lines
                this.tooltip.html(""); // Clear existing HTML content
                this.tooltip.append("div").text(`Category: ${d.category}`); // Use div for new line
                this.tooltip.append("div").text(`Value: ${d.value.toLocaleString()}`); // Use div for new line

                // Position the tooltip relative to the mouse pointer
                this.tooltip.style("left", (event.pageX + 10) + "px")
                    .style("top", (event.pageY - 28) + "px");
            })
            .on("mouseout", () => { // Tooltip hide on mouseout
                this.tooltip.style("opacity", 0);
            });

        // Add transition for smooth updates of existing bars
        bars.transition().duration(500)
            .attr('x', d => xScale(d.category)!)
            .attr('y', d => yScale(d.value))
            .attr('width', xScale.bandwidth())
            .attr('height', d => height - yScale(d.value))
            .attr('fill', d => d.color);

        // Draw X-Axis
        this.xAxisGroup.call(d3.axisBottom(xScale))
            .selectAll("text")
            .attr("transform", "rotate(-45)") // Rotate labels for better readability if they overlap
            .style("text-anchor", "end")
            .style("fill", this.formattingSettings.general.axisTextColor.value.value); // Apply axis text color from settings

        // Draw Y-Axis
        this.yAxisGroup.call(d3.axisLeft(yScale))
            .selectAll("text")
            .style("fill", this.formattingSettings.general.axisTextColor.value.value); // Apply axis text color from settings
    }

    // This method is required by IVisual and returns the formatting model for the visual
    public getFormattingModel(): powerbi.visuals.FormattingModel {
        return this.formattingSettingsService.buildFormattingModel(this.formattingSettings);
    }

    // Destroy method: Called when the visual is removed from the report
    public destroy() {
        // Clean up the dynamically created tooltip div to prevent memory leaks
        this.tooltip.remove();
    }
}

Step 6: Update src/settings.ts

Ensure your settings file uses formattingSettings.SimpleCard as the base for standard formatting cards. This file defines the properties that will appear in the Power BI format pane.

Correct src/settings.ts Structure:

import { formattingSettings } from "powerbi-visuals-utils-formattingmodel";

// Corrected: Use formattingSettings.SimpleCard as the base for simple formatting cards
import FormattingSettingsCard = formattingSettings.SimpleCard;
import FormattingSettingsSlice = formattingSettings.Slice;
import FormattingSettingsModel = formattingSettings.Model;

/**
 * General Settings Card for your visual.
 * This card will appear in the format pane in Power BI.
 */
export class GeneralSettings extends FormattingSettingsCard {
    // Example: A color picker property for the bars
    fill = new formattingSettings.ColorPicker({
        name: "fill", // Internal name for the property (must match capabilities.json)
        displayName: "Bar Color", // Label displayed in Power BI Desktop's format pane
        value: { value: "#1F77B4" } // Default color (a common Power BI blue)
    });

    // Example: A color picker for axis text color
    axisTextColor = new formattingSettings.ColorPicker({
        name: "axisTextColor",
        displayName: "Axis Text Color",
        value: { value: "#333333" } // Default to dark gray
    });

    // Mandatory properties for a FormattingSettingsCard
    name: string = "general"; // Internal name for this card (must match object name in capabilities.json)
    displayName: string = "General"; // Label for this card in Power BI Desktop
    slices: Array<FormattingSettingsSlice> = [this.fill, this.axisTextColor]; // Array of properties (slices) on this card
}

/**
 * Defines the overall formatting settings model for the visual.
 * This class is the root for all formatting cards and properties that appear in the format pane.
 */
export class VisualFormattingSettingsModel extends FormattingSettingsModel {
    // Instantiate your formatting cards here.
    // The 'general' card will contain the 'fill' color picker defined above.
    general = new GeneralSettings();

    // Add all your formatting cards to this array.
    cards: Array<FormattingSettingsCard> = [this.general];
}

Step 7: Update capabilities.json

This file defines data roles, data mappings, objects (which link to your settings.ts), and crucial privileges for your visual. The privileges array is essential and must contain specific, case-sensitive names if your visual requires certain permissions (e.g., web access for external data, local storage).

Correct capabilities.json Structure (Updated with Minimal privileges):

{
    "dataRoles": [
        {
            "name": "category",
            "displayName": "Category",
            "kind": "Grouping"
        },
        {
            "name": "measure",
            "displayName": "Measure",
            "kind": "Measure"
        }
    ],
    "dataViewMappings": [
        {
            "categorical": {
                "categories": {
                    "for": { "in": "category" }
                },
                "values": {
                    "select": [
                        { "bind": { "to": "measure" } }
                    ]
                }
            }
        }
    ],
    "objects": {
        "general": {
            "displayName": "General",
            "properties": {
                "fill": { // This 'fill' property maps to the 'fill' in settings.ts
                    "displayName": "Bar Color",
                    "type": { "fill": { "solid": { "color": true } } }
                },
                "axisTextColor": { // This 'axisTextColor' property maps to the 'axisTextColor' in settings.ts
                    "displayName": "Axis Text Color",
                    "type": { "fill": { "solid": { "color": true } } }
                }
            }
        }
    },
    "privileges": [
        {
            "name": "WebAccess", // Example: If your visual needs to access external web resources
            "essential": true
        }
        // Add other privileges like 'FileAccess', 'Clipboard', 'Download' if needed
    ]
}

Step 8: Start the Development Server

The development server allows you to preview your visual in Power BI Desktop as you make changes.

  • In your project directory, run:
    pbiviz start
    
    You should see info Server listening on port 8080. Ignore any "Cannot GET /" messages in your browser if you try to open https://localhost:8080/ directly; this is normal as it's not a web page.

Step 9: Enable Developer Mode in Power BI Desktop

If you don't see the "Developer Visual" in Power BI Desktop, you need to enable developer mode. The option's location can vary slightly by Power BI Desktop version.

  • Open Power BI Desktop.
  • Go to File > Options and settings > Options.
  • In the Options window, navigate to GLOBAL > Security.
  • Under the "Data Extensions" section, select the radio button for: "(Not Recommended) Allow extension to load without validation or warning".
  • Click OK.
  • Restart Power BI Desktop for the changes to take effect.

Step 10: Test Your Visual in Power BI Desktop

Now, let's see your custom visual in action!

  • Ensure your pbiviz start server is still running in your terminal.
  • Open Power BI Desktop.
  • Go to the "Insert" tab on the ribbon.
  • In the "Visualizations" pane, click the "Developer Visual" icon. It usually looks like a prompt or a circle with a developer-related icon, often found at the bottom of the Visualizations pane.
  • Add the Developer Visual to your report canvas.
  • Your custom visual should now load and display. Any changes you save in your TypeScript files will automatically refresh in Power BI Desktop (you might need to click the refresh button on the visual or interact with it). You can also drag data fields into the visual's data roles and test the formatting options in the format pane.

Step 11: Package Your Visual for Distribution

Once your visual is complete and tested, you can package it into a .pbiviz file for distribution or for use in the Power BI service.

  • Stop the development server (by pressing Ctrl+C in the terminal where pbiviz start is running).
  • Run the package command in your project directory:
    pbiviz package
    
    This will create a .pbiviz file in the dist folder of your project. This is the single file you can share or import into Power BI.

Note on Linter Errors: You might see "Linter found X errors and Y warnings." This indicates coding style or potential issues identified by ESLint (a code linting tool). While these don't prevent packaging, it's good practice to fix them for cleaner, more maintainable code. You can run pbiviz package --verbose to see detailed linter messages.

Step 12: Import Your Packaged Visual into Power BI Desktop

This .pbiviz file can be shared and imported into any Power BI Desktop instance, even without developer mode enabled.

  • Open Power BI Desktop.
  • Go to the "Insert" tab on the ribbon.
  • In the "Visualizations" pane, click the three dots (...) and select "Import a visual from a file".
  • In the "Open" dialog, browse to your project's dist folder and select the .pbiviz file (e.g., MyD3VisualProject.pbiviz).
  • Confirm the import. Your custom visual will now appear as a new icon in the Visualizations pane, ready for use!

By following these detailed steps, you should be fully equipped to create, test, and deploy your custom Power BI visuals. Remember to iterate: start simple, get it working, then add complexity and features like axes, tooltips, and custom formatting options.

Raushan Ranjan

Microsoft Certified Trainer

.NET | Azure | Power Platform | WPF | Qt/QML Developer

Power BI Developer | Data Analyst

📞 +91 82858 62455
🌐 raushanranjan.azurewebsites.net
🔗 linkedin.com/in/raushanranjan

Comments

Popular posts from this blog

Module 1 - Lesson 1: Getting Started with Power BI

Power BI Advanced learning

Module 1 - Lesson 2: Getting Data from Multiple Sources

Module 1 - Lesson 3: Resolve Data Import Errors in Power BI

Module 2 - Lesson 1: Introduction to Power Query Editor