chartbookData =transpose(rawData).map(d => ({...d,Date:newDate(d.Date),Description: d.Description,name: d.name,Value:+d.Value,Metric: d.Metric}));// Define metrics, property types and colors for linesmetrics = ["Units Sold","Active Listings","Average Price","Sales to Active Listing Ratio"];propTypes = ["Market Total","Apartment Unit","Attached","Detached"];// Line color configurationscolorConfig = ({"Market Total": {dataColor:"#C5E7D5",trendColor:"#2D6D4B" },"Apartment Unit": {dataColor:"#D4C3E9",trendColor:"#583286" },"Attached": {dataColor:"#F4CDD9",trendColor:"#8B1E3F" },"Detached": {dataColor:"#C5D5E7",trendColor:"#3B6391" }});// Format configurationsformatConfig = ({"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 configurationsfontSizeConfig = ({desktop:16,landscape:15,portrait:16,desktopTitle:18,landscapeTitle:14,portraitTitle:16,desktopAxis:12,landscapeAxis:11,portraitAxis:14});// Axis configurationsaxisConfig = ({desktop: {tickSize:8,tickPadding:8 },landscape: {tickSize:8,tickPadding:8 },portrait: {tickSize:10,tickPadding:10 },price: {tickPadding:12,landscapeTickPadding:12 }});// Line thicknesslineConfig = ({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 */functioncreateChart(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 typeconst yFormat = formatConfig[metric] || formatConfig.default;// Average price labels are longer, will need to shift to the left moreconst 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;caseisDesktop:return leftMarginConfig.desktop;caseisLandscape:return leftMarginConfig.desktop;default:return leftMarginConfig.base; } })();// Different container widths and heights depending on desktop vs mobileconst 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:returnMath.min(250, containerWidth *0.6);caseisDesktop:returnMath.min(350, containerWidth *0.6);default:returnMath.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 dataconst 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 slidersconst 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")(newDate(availableDates[0]))}</span> <span id="${chartId}_max_date">${d3.utcFormat("%B %Y")(newDate(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 */functiongetPropertyTypeTitle(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 slidersconst 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 chartconst 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 */functionsetTimeRange(months) {const maxDate =newDate(availableDates[availableDates.length-1]);let minDate;// Set slider to min and max position if "all" button selected, otherwise get differenceif (months ==='all') { minDate =newDate(availableDates[0]); minInput.value=0; } else { minDate =newDate(maxDate); minDate.setMonth(minDate.getMonth() - months);// Find the closest available dateconst 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 panelconst 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;constevent=newEvent('input'); input.dispatchEvent(event); }); activeTabPanel.querySelectorAll('.chart-range-input.max').forEach(input => { input.value= maxInput.value;constevent=newEvent('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 */functionupdateButtonStates(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 buttonsif (rangeButtons.length>0) { rangeButtons.forEach(button => { button.addEventListener('click', (e) => {const range = e.target.dataset.range;// Update button states firstupdateButtonStates(container, e.target);// Then update time rangeswitch(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 */functionrenderChart(minDate, maxDate) {// Filter to dates within rangeconst filteredData = chartData.filter(d => d.Date>= minDate && d.Date<= maxDate );return Plot.plot({width: containerWidth,height: containerHeight,marginLeft: leftMargin,marginRight: (() => {switch (true) {caseisDesktop:return50;caseisLandscape:return40;default:return70; } })(),marginTop:0,marginBottom:30,style: {fontFamily:"'Hellix', sans-serif",overflow:"visible",fontSize: (() => {switch (true) {caseisDesktop:return fontSizeConfig.desktop;caseisLandscape:return fontSizeConfig.landscape;default:return fontSizeConfig.portrait; } })(), },title: metric,titleAnchor:"start",titleDx: (() => {switch (true) {caseisDesktop:return leftMargin;caseisLandscape:return leftMargin;default:return16; } })(),titleDy:-30,titleFontSize: (() => {switch (true) {caseisDesktop:return fontSizeConfig.desktopTitle;caseisLandscape:return fontSizeConfig.landscapeTitle;default:return fontSizeConfig.portraitTitle; } })(),titleFontWeight:500,color: {domain: ["Actual","Trend"],range: [colorConfig[propType].dataColor, colorConfig[propType].trendColor] },// X axis configx: {type:"time",label:null,domain: [minDate, maxDate],tickFormat: (() => {// Calculate the time difference in monthsconst monthsDiff = (maxDate.getFullYear() - minDate.getFullYear()) *12+ (maxDate.getMonth() - minDate.getMonth());// If range is 12 months or less, show month and yearif (monthsDiff <=12) {return"%b %Y"; }// If range is more than 12 months, only show yearreturn"%Y"; })(),ticks: (() => {// Calculate the time difference in monthsconst 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 formatif (monthsDiff <=12) {if (isLandscape || isDesktop) {return d3.timeMonth.every(2); } else {return d3.timeMonth.every(3); } }// For longer ranges, adjust tick spacing based on orientationif (isDesktop || isLandscape) {return d3.timeYear.every(1); } else {return d3.timeYear.every(4);// Every 4 years for portrait mobile } })(),fontSize: (() => {switch (true) {caseisDesktop:return fontSizeConfig.desktopAxis;caseisLandscape:return fontSizeConfig.landscapeAxis;default:return fontSizeConfig.portraitAxis; } })(),tickSize: (() => {switch (true) {caseisDesktop:return axisConfig.desktop.tickSize;caseisLandscape:return axisConfig.landscape.tickSize;default:return axisConfig.portrait.tickSize; } })(),tickPadding: (() => {switch (true) {caseisDesktop:return axisConfig.desktop.tickPadding;caseisLandscape:return axisConfig.landscape.tickPadding;default:return axisConfig.portrait.tickPadding; } })() },// Y axis configy: {label:null,nice:false,domain: (() => {switch (true) {caseisLandscape:return [d3.min(filteredData, d => d.Value) *0.5, d3.max(filteredData, d => d.Value) *1.3];caseisDesktop: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) {caseisDesktop:return fontSizeConfig.desktopAxis;caseisLandscape:return fontSizeConfig.landscapeAxis;default:return fontSizeConfig.portraitAxis; } })(),tickSize: (() => {switch (true) {caseisDesktop:return axisConfig.desktop.tickSize;caseisLandscape: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;caseisDesktop:return axisConfig.desktop.tickPadding;caseisLandscape:return axisConfig.landscape.tickPadding;default:return axisConfig.portrait.tickPadding; } })(),// Reduce amount of y axis ticks for average price charts for clarityticks: (() => {switch (true) {case metric ==="Active Listings":return7;case metric ==="Units Sold":return6;case metric ==="Sales to Active Listing Ratio":return7;default:return5; } })() },// Plot elementsmarks: [// Line for the data series Plot.line(filteredData, {x:"Date",y:"Value",stroke:"Metric",strokeWidth: (() => {switch (true) {caseisDesktop:return lineConfig.desktop;caseisLandscape:return lineConfig.landscape;default:return lineConfig.portrait; } })(),// Set format for tooltiptip: {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 */functionupdateChart(e) {// Get current slider index valuesconst currentMin =+minInput.value;const currentMax =+maxInput.value;if (e && e.target=== minInput) {// Moving min sliderif (currentMax - currentMin <2) { // Make sure there is a two month gap between the sliders minInput.value= currentMax -2; } } else {// Moving max sliderif (currentMax - currentMin <2) { maxInput.value= currentMin +2; } }// Get final values after adjustmentsconst finalMin =+minInput.value;const finalMax =+maxInput.value;// Convert slider indices to actual datesconst minDate =newDate(availableDates[finalMin]);const maxDate =newDate(availableDates[finalMax]);// Update date displaysconst 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 eventupdateChart(null);return container;}
// Create all chart combinations and put them in a key value objectcharts = {let chartObj = {};for (let p of propTypes) {for (let m of metrics) { chartObj[`${m}_${p}`] =createChart(chartbookData, p, m); } }return chartObj;}
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.