GVR Economics
  • Blog
  • Analytics
    • Residential
    • Commercial
  • Downloads
    • Data Downloads
    • ChartBook
  • FAQs
  • Links
  • Archive
  • About
  • GVR Home
  1. ChartBook Mobile
  • Residential Analytics

  • ChartBook
  • ChartBook Mobile
  • Residential Dashboard
  • Forecasts

ChartBook Mobile

chartbookData = transpose(rawData).map(d => ({
  ...d,
  Date: new Date(d.Date),
  Description: d.Description,
  name: d.name,
  Value: +d.Value,
  Metric: d.Metric
}));

// Define metrics, property types and colors for lines
metrics = ["Units Sold", "Active Listings", "Average Price", "Sales to Active Listing Ratio"];
propTypes = ["Market Total", "Apartment Unit", "Attached", "Detached"];

// Line color configurations
colorConfig = ({
  "Market Total": {
    dataColor: "#C5E7D5",  
    trendColor: "#2D6D4B"  
  },
  "Apartment Unit": {
    dataColor: "#D4C3E9",  
    trendColor: "#583286"  
  },
  "Attached": {
    dataColor: "#F4CDD9",  
    trendColor: "#8B1E3F"  
  },
  "Detached": {
    dataColor: "#C5D5E7",  
    trendColor: "#3B6391"  
  }
});

// Format configurations
formatConfig = ({
  "Average Price": "$,.0f",
  "Sales to Active Listing Ratio": ".0%",
  "default": ",.0f"
});

// Margin configurations (need to define since price labels are longer)
leftMarginConfig = ({
  base: 85,
  price: 130,
  desktop: 90,
  desktopPrice: 120
});

// Font size configurations
fontSizeConfig = ({
  desktop: 16,
  landscape: 15,
  portrait: 16,
  desktopTitle: 18,
  landscapeTitle: 14,
  portraitTitle: 16,
  desktopAxis: 12,
  landscapeAxis: 11,
  portraitAxis: 14
});

// Axis configurations
axisConfig = ({
  desktop: {
    tickSize: 8,
    tickPadding: 8
  },
  landscape: {
    tickSize: 8,
    tickPadding: 8
  },
  portrait: {
    tickSize: 10,
    tickPadding: 10
  },
  price: {
    tickPadding: 12,
    landscapeTickPadding: 12
  }
});

// Line thickness
lineConfig = ({
  desktop: 2,
  landscape: 2,
  portrait: 2
});
d3 = require('d3')

/**
 * Creates a chart container with interactive elements
 * @param {Array} chartbookData - Array of data objects containing chart data
 * @param {string} propType - Property type to filter data by
 * @param {string} metric - Metric type to filter data by
 * @returns {HTMLElement} Container with chart and interactive elements
 * @description
 * Creates a container with:
 * - Property type title (for Units Sold only)
 * - Time range selector buttons
 * - Line chart
 * - Range slider for date selection
 * The chart is responsive and adjusts layout based on screen size/orientation
 */
function createChart(chartbookData, propType, metric) {
  const chartData = chartbookData.filter(
    d => d.Description === propType && d.name === metric
  );
  const isDesktop = width > 768;
  const isLandscape = window.innerWidth > window.innerHeight;
  
  // Format the y axis label based on metric type
  const yFormat = formatConfig[metric] || formatConfig.default;

  // Average price labels are longer, will need to shift to the left more
  const leftMargin = (() => {
    switch (true) {
      case isDesktop && metric === "Average Price":
        return leftMarginConfig.desktopPrice;
      case isLandscape && metric === "Average Price":
        return leftMarginConfig.desktopPrice;
      case metric === "Average Price":
        return leftMarginConfig.price;
      case isDesktop:
        return leftMarginConfig.desktop;
      case isLandscape:
        return leftMarginConfig.desktop;
      default:
        return leftMarginConfig.base;
    }
  })();

  // Different container widths and heights depending on desktop vs mobile
  const containerWidth = (() => {
    switch (true) {
      case isLandscape && !isDesktop:
        return width * 1.1;
      case !isDesktop:
        return width * 1.2;
      default:
        return width;
    }
  })();

  const containerHeight = (() => {
    switch (true) {
      case isLandscape && !isDesktop:
        return Math.min(250, containerWidth * 0.6);
      case isDesktop:
        return Math.min(350, containerWidth * 0.6);
      default:
        return Math.min(750, containerWidth * 1.2);
    }
  })();

  // Create unique IDs for chart's (e.g. Units_Sold_Detached)
  const chartId = `${metric}_${propType}`.replace(/\s+/g, '_');

  // Get all unique dates from the data
  const availableDates = chartData.map(d => d.Date.getTime()).sort();
  
  // Create time range selector buttons (make sure only appears once above Units Sold chart)
  const timeRangeButtons = metric === "Units Sold" ? html`<div class="time-range-selectors">
    <button data-range="6m">6 m</button>
    <button data-range="1y">1 yr</button>
    <button data-range="3y">3 yr</button>
    <button data-range="5y">5 yr</button>
    <button data-range="10y">10 yr</button>
    <button data-range="all" class="active">All</button>
  </div>` : '';
  
  // Create the range input with both min and max sliders
  const rangeInput = html`<div style="margin-top: 10px;">
    <form class="slider-container">
      <input type="range" 
        min="0" 
        max="${availableDates.length - 1}" 
        step="1" 
        value="0"
        id="${chartId}_min"
        class="chart-range-input min"
      >
      <input type="range" 
        min="0" 
        max="${availableDates.length - 1}" 
        step="1" 
        value="${availableDates.length - 1}"
        id="${chartId}_max"
        class="chart-range-input max"
      >
    </form>
    <div style="display: flex; justify-content: space-between; margin-top: 5px; font-size: ${isLandscape ? fontSizeConfig.landscape : fontSizeConfig.portrait}px">
      <span id="${chartId}_min_date">${d3.utcFormat("%B %Y")(new Date(availableDates[0]))}</span>
      <span id="${chartId}_max_date">${d3.utcFormat("%B %Y")(new Date(availableDates[availableDates.length - 1]))}</span>
    </div>
  </div>`;

    /**
   * Generates an h2 heading tag for property type title
   * @param {string} metric - The metric type (e.g. "Units Sold", "Average Price")
   * @param {string} propType - The property type to display
   * @param {boolean} isLandscape - Whether the display is in landscape orientation
   * @param {number} width - The width of the container
   * @returns {string} HTML string containing the title element or empty div
   * @description Only generates title for "Units Sold" metric, returns empty div otherwise
   */
  function getPropertyTypeTitle(metric, propType, isLandscape, width) {
    if (metric !== "Units Sold") return '<div></div>';
    
    return `<h2 style="
      font-size: 1.1em;
      margin: 0;
      color: #000000;
      font-weight: 600;
      width: 100%;
      padding-bottom: 8px;
      margin-bottom: 16px;
      text-align: left;
    ">${propType}
    </h2>`;
  }

  // Create the container holding the property type title, time range buttons, line chart and sliders
  const container = html`
    <div id="${chartId}"> 
      <div class="chart-header" style="
        display: flex;
        flex-direction: column;
        gap: 16px;
        margin-bottom: 10px;
      ">
        ${getPropertyTypeTitle(metric, propType, isLandscape, width)}
        <div class="time-range-container" style="
          display: flex;
          justify-content: center;
          overflow-x: auto;
          white-space: nowrap;
          -webkit-overflow-scrolling: touch;
          padding: 0 5px;
        ">
          ${timeRangeButtons}
        </div>
      </div>
      <div class="chart"></div>
      ${rangeInput}
    </div>
  `

  // Get current range values from chart_id and set up initial chart
  const minInput = container.querySelector(`#${chartId}_min`);
  const maxInput = container.querySelector(`#${chartId}_max`);
  const chartDiv = container.querySelector('.chart');
  const rangeButtons = container.querySelectorAll('.time-range-selectors button');

  /**
   * Sets the time range for the chart based on button selection
   * @param {number|string} months - Number of months to show or "all" for full range
   */
  function setTimeRange(months) {
    const maxDate = new Date(availableDates[availableDates.length - 1]);
    let minDate;
    
    // Set slider to min and max position if "all" button selected, otherwise get difference
    if (months === 'all') {
      minDate = new Date(availableDates[0]);  
      minInput.value = 0;
    } else {
      minDate = new Date(maxDate);
      minDate.setMonth(minDate.getMonth() - months);
      
      // Find the closest available date
      const targetTime = minDate.getTime();
      const closestIndex = availableDates.findIndex(date => date >= targetTime);
      minInput.value = Math.max(0, closestIndex);
    }
    
    // Set max slider to max date value when button is clicked
    maxInput.value = availableDates.length - 1;
    
    // Find the current active tab panel
    const activeTabPanel = container.closest('.tab-pane.active');
    if (!activeTabPanel) return;
    
    // Update only charts within the current tab panel
    activeTabPanel.querySelectorAll('.chart-range-input.min').forEach(input => {
      input.value = minInput.value;
      const event = new Event('input');
      input.dispatchEvent(event);
    });
    activeTabPanel.querySelectorAll('.chart-range-input.max').forEach(input => {
      input.value = maxInput.value;
      const event = new Event('input');
      input.dispatchEvent(event);
    });
  }

    /**
   * Updates the active state of time range selector buttons within a tab panel
   * @param {HTMLElement} container - The container element that holds the chart and buttons
   * @param {HTMLElement} activeButton - The button that was clicked to trigger the update
   * @description
   * This function handles the visual state of time range selector buttons by:
   * 1. Finding the active tab panel containing the buttons
   * 2. Removing the 'active' class from all buttons in that panel
   * 3. If a button was clicked, adding the 'active' class to all buttons with matching time range
   * This ensures consistent button highlighting across all charts in the same tab panel
   */
  function updateButtonStates(container, activeButton) {
    const activeTabPanel = container.closest('.tab-pane.active');

    activeTabPanel.querySelectorAll('.time-range-selectors button').forEach(btn => {
      btn.classList.remove('active');
    });
    
    if (activeButton) {
      const range = activeButton.dataset.range;
      activeTabPanel.querySelectorAll(`.time-range-selectors button[data-range="${range}"]`).forEach(btn => {
        btn.classList.add('active');
      });
    }
  }

  // Add click handlers to range buttons
  if (rangeButtons.length > 0) {
    rangeButtons.forEach(button => {
      button.addEventListener('click', (e) => {
        const range = e.target.dataset.range;
        
        // Update button states first
        updateButtonStates(container, e.target);
        
        // Then update time range
        switch(range) {
          case '6m':
            setTimeRange(6);
            break;
          case '1y':
            setTimeRange(12);
            break;
          case '3y':
            setTimeRange(36);
            break;
          case '5y':
            setTimeRange(60);
            break;
          case '10y':
            setTimeRange(120);
            break;
          case 'all':
            setTimeRange("all");
            break;
        }
      });
    });
  }

  /**
   * Renders a line chart with the given date range
   * @param {Date} minDate - Start date for chart data
   * @param {Date} maxDate - End date for chart data
   * @returns {Plot} Observable Plot object
   */
  function renderChart(minDate, maxDate) {
    // Filter to dates within range
    const filteredData = chartData.filter(d => 
      d.Date >= minDate && d.Date <= maxDate
    );

    return Plot.plot({
      width: containerWidth,
      height: containerHeight,
      marginLeft: leftMargin,
      marginRight: (() => {
        switch (true) {
          case isDesktop:
            return 50;
          case isLandscape:
            return 40;
          default:
            return 70;
        }
      })(),
      marginTop: 0,
      marginBottom: 30,
      style: {
        fontFamily: "'Hellix', sans-serif",
        overflow: "visible",
        fontSize: (() => {
          switch (true) {
            case isDesktop:
              return fontSizeConfig.desktop;
            case isLandscape:
              return fontSizeConfig.landscape;
            default:
              return fontSizeConfig.portrait;
          }
        })(),
      },
      title: metric,
      titleAnchor: "start", 
      titleDx: (() => {
        switch (true) {
          case isDesktop:
            return leftMargin;
          case isLandscape:
            return leftMargin;
          default:
            return 16;
        }
      })(),
      titleDy: -30,
      titleFontSize: (() => {
        switch (true) {
          case isDesktop:
            return fontSizeConfig.desktopTitle;
          case isLandscape:
            return fontSizeConfig.landscapeTitle;
          default:
            return fontSizeConfig.portraitTitle;
        }
      })(),
      titleFontWeight: 500,
      color: {
        domain: ["Actual", "Trend"],
        range: [colorConfig[propType].dataColor, colorConfig[propType].trendColor]
      },
      // X axis config
      x: {
        type: "time",
        label: null,
        domain: [minDate, maxDate],
        tickFormat: (() => {
          // Calculate the time difference in months
          const monthsDiff = (maxDate.getFullYear() - minDate.getFullYear()) * 12 + 
                            (maxDate.getMonth() - minDate.getMonth());
          
          // If range is 12 months or less, show month and year
          if (monthsDiff <= 12) {
            return "%b %Y";
          }
          // If range is more than 12 months, only show year
          return "%Y";
        })(),
        ticks: (() => {
          // Calculate the time difference in months
          const monthsDiff = (maxDate.getFullYear() - minDate.getFullYear()) * 12 + 
                            (maxDate.getMonth() - minDate.getMonth());
          
          // If range is 12 months or less, show month and year with range depending on format
          if (monthsDiff <= 12) {
            if (isLandscape || isDesktop) {
              return d3.timeMonth.every(2);
            } else {
              return d3.timeMonth.every(3);
            }
          }
          
          // For longer ranges, adjust tick spacing based on orientation
          if (isDesktop || isLandscape) {
              return d3.timeYear.every(1);
          } else {
              return d3.timeYear.every(4); // Every 4 years for portrait mobile
          }         
        })(),
        fontSize: (() => {
          switch (true) {
            case isDesktop:
              return fontSizeConfig.desktopAxis;
            case isLandscape:
              return fontSizeConfig.landscapeAxis;
            default:
              return fontSizeConfig.portraitAxis;
          }
        })(),
        tickSize: (() => {
          switch (true) {
            case isDesktop:
              return axisConfig.desktop.tickSize;
            case isLandscape:
              return axisConfig.landscape.tickSize;
            default:
              return axisConfig.portrait.tickSize;
          }
        })(),
        tickPadding: (() => {
          switch (true) {
            case isDesktop:
              return axisConfig.desktop.tickPadding;
            case isLandscape:
              return axisConfig.landscape.tickPadding;
            default:
              return axisConfig.portrait.tickPadding;
          }
        })()
      },
      // Y axis config
      y: {
        label: null,
        nice: false,
        domain: (() => {
          switch (true) {
            case isLandscape:
              return [d3.min(filteredData, d => d.Value) * 0.5, d3.max(filteredData, d => d.Value) * 1.3];
            case isDesktop:
              return [d3.min(filteredData, d => d.Value) * 0.5, d3.max(filteredData, d => d.Value) * 1.3];
            default:
              return [d3.min(filteredData, d => d.Value) * 0.7, d3.max(filteredData, d => d.Value) * 1.09]; // Default is portrait on mobile mode
          }
        })(),
        tickFormat: d => d3.format(yFormat)(d),
        fontSize: (() => {
          switch (true) {
            case isDesktop:
              return fontSizeConfig.desktopAxis;
            case isLandscape:
              return fontSizeConfig.landscapeAxis;
            default:
              return fontSizeConfig.portraitAxis;
          }
        })(),
        tickSize: (() => {
          switch (true) {
            case isDesktop:
              return axisConfig.desktop.tickSize;
            case isLandscape:
              return axisConfig.landscape.tickSize;
            default:
              return axisConfig.portrait.tickSize;
          }
        })(),
        tickPadding: (() => {
          switch (true) {
            case metric === "Average Price" && isLandscape:
              return axisConfig.price.landscapeTickPadding;
            case metric === "Average Price":
              return axisConfig.price.tickPadding;
            case isDesktop:
              return axisConfig.desktop.tickPadding;
            case isLandscape:
              return axisConfig.landscape.tickPadding;
            default:
              return axisConfig.portrait.tickPadding;
          }
        })(),
        // Reduce amount of y axis ticks for average price charts for clarity
        ticks: (() => {
          switch (true) {
            case metric === "Active Listings":
              return 7;
            case metric === "Units Sold":
              return 6;
            case metric === "Sales to Active Listing Ratio":
              return 7;
            default:
              return 5;
          }
        })()
      },
      // Plot elements
      marks: [
        // Line for the data series
        Plot.line(filteredData, {
          x: "Date",
          y: "Value",
          stroke: "Metric",
          strokeWidth: (() => {
            switch (true) {
              case isDesktop:
                return lineConfig.desktop;
              case isLandscape:
                return lineConfig.landscape;
              default:
                return lineConfig.portrait;
            }
          })(),
          // Set format for tooltip
          tip: {
            format: {
              x: x => d3.utcFormat("%B %Y")(x),
              y: y => d3.format(yFormat)(y)
            }
          }
        }),
        // Zero line reference
        // Plot.ruleY([0])
      ],
    });
  }

  /**
   * Updates the chart display based on slider input
   * @param {Event} e - Input event from slider
   * @description
   * Handles slider input events to:
   * 1. Maintain minimum gap between sliders
   * 2. Update date displays
   * 3. Re-render chart with new date range
   */
  function updateChart(e) {
    // Get current slider index values
    const currentMin = +minInput.value;
    const currentMax = +maxInput.value;
    
    if (e && e.target === minInput) {
      // Moving min slider
      if (currentMax - currentMin < 2) { // Make sure there is a two month gap between the sliders
        minInput.value = currentMax - 2; 
      }
    } else {
      // Moving max slider
      if (currentMax - currentMin < 2) {
        maxInput.value = currentMin + 2;
      }
    }
    
    // Get final values after adjustments
    const finalMin = +minInput.value;
    const finalMax = +maxInput.value;
    
    // Convert slider indices to actual dates
    const minDate = new Date(availableDates[finalMin]);
    const maxDate = new Date(availableDates[finalMax]);
    
    // Update date displays
    const minDateDisplay = container.querySelector(`#${chartId}_min_date`);
    const maxDateDisplay = container.querySelector(`#${chartId}_max_date`);
    
    if (minDateDisplay && maxDateDisplay) {
      minDateDisplay.textContent = d3.utcFormat("%B %Y")(minDate);
      maxDateDisplay.textContent = d3.utcFormat("%B %Y")(maxDate);
    }
    
    // Clear and re-render the chart with new date range
    chartDiv.innerHTML = '';
    chartDiv.appendChild(renderChart(minDate, maxDate));
  }

  // Add input event listeners to both sliders
  minInput.addEventListener('input', updateChart);
  maxInput.addEventListener('input', updateChart);

  // Initial render with no event
  updateChart(null);

  return container;
}
// Create all chart combinations and put them in a key value object
charts = {
  let chartObj = {};
  for (let p of propTypes) {
    for (let m of metrics) {
      chartObj[`${m}_${p}`] = createChart(chartbookData, p, m);
    }
  }
  return chartObj;
}
  • MKT TTL
  • APT
  • ATT
  • DET
  • FAQ
charts["Units Sold_Market Total"]
charts["Average Price_Market Total"]
charts["Active Listings_Market Total"]
charts["Sales to Active Listing Ratio_Market Total"]
charts["Units Sold_Apartment Unit"]
charts["Average Price_Apartment Unit"]
charts["Active Listings_Apartment Unit"]
charts["Sales to Active Listing Ratio_Apartment Unit"]
charts["Units Sold_Attached"]
charts["Average Price_Attached"]
charts["Active Listings_Attached"]
charts["Sales to Active Listing Ratio_Attached"]
charts["Units Sold_Detached"]
charts["Average Price_Detached"]
charts["Active Listings_Detached"]
charts["Sales to Active Listing Ratio_Detached"]

Frequently Asked Questions

Q: What is ChartBook Mobile?

A: ChartBook Mobile is a lightweight, responsive web application designed to provide users with an interactive ability to view the ChartBook Report in a mobile-friendly format. Optimized for both desktop and mobile devices, it offers a user-friendly interface that adapts to various screen sizes.


Q: How do I use ChartBook Mobile?

A: Here is a brief guide on how to use ChartBook Mobile:

  • Select a property type by clicking one of the tabs at the top (default is Market Total).
  • Each tab includes four charts showing key metrics for GVR’s real estate market.
  • You can use the pre-filtered time range selectors at the top of the page to quickly analyze preset historical windows of time, or alternatively, use the range slide at the bottom of each chart to display a custom range.
  • Tap or click a specific point on the line chart to view a tooltip with details like the value and metric type.

Q: What is the difference between the Actual and Trend lines?

A: The difference between Actual and Trend data are as follows:

  • Actual: These are the raw, unadjusted month-end figures for each data series.
  • Trend: The trend component is generated by applying the X13 ARIMA SEATS seasonal adjustment model to the raw unadjusted data. The trend can be helpful in visualizing data with strong seasonal patterns, such as sales and inventory levels.

Q: What do the property type categories mean?

A: Below is a definition of each property type:

  • Apartment Unit: A residential unit that is typically part of a larger multifamily residential building. Ownership of grounds and amenities is often part of a common ownership structure, such as a strata.
  • Attached: A residential unit that is typically part of a ground-oriented multifamily residential complex (e.g., townhouse), or a unit that shares a common wall with an adjoining unit, such as a duplex or triplex.
  • Detached: A residential unit that typically does not share any common walls with adjoining units and where the land is not part of a common ownership structure, such as a strata. These are most commonly represented in the data as “single-detached homes.”
  • Market Total: The sum of residential attached, apartment unit, detached, multifamily, and vacant land listings on the MLS® system. Please note that multifamily and vacant land properties are not plotted in ChartBook as these typologies typically represent a small number of transactions in any given month. However, because these property types represent valid sales and inventory of residential properties on the MLS® system, they are included in the market totals for completeness.

Q: What do the metrics represent?

A: Below is a definition of each metric shown within ChartBook and ChartBook Mobile:

  • Units Sold: The month-end total number of residential properties sold on the MLS® system.
  • Average Price: This is a raw average, calculated by dividing the total month-end dollar volume of residential sales by the total month-end number of residential units sold.
  • Active Listings: The month-end total number of residential properties listed for sale on the MLS® system.
  • Sales-to-Active-Listings Ratio (SALR): The SALR is calculated as the ratio of the month-end total number of residential properties sold divided by the month-end total number of residential properties listed for sale on the MLS® system. It is often used as a measure of market balance, relating available supply to current demand. See definitions of market balance below for more details.

Q: What areas do the data cover in ChartBook and ChartBook Mobile?

A: Areas covered by Greater Vancouver REALTORS® include: Bowen Island, Burnaby, Coquitlam, Maple Ridge, New Westminster, North Vancouver, Pitt Meadows, Port Coquitlam, Port Moody, Richmond, South Delta, Squamish, Sunshine Coast, Vancouver, West Vancouver, and Whistler.

The data displayed in ChartBook are aggregations across all the listed regions.

  • Blog Archive