Diego Ripley
2025-08-10 10:02:41 -04:00
parent bc9e7b5f8c
commit f5a2831cf6
13 changed files with 1882 additions and 773 deletions
File diff suppressed because one or more lines are too long
@@ -0,0 +1,452 @@
import maplibregl from 'maplibre-gl';
import * as ss from 'simple-statistics';
import PeliasGeocoder from './pelias-geocoder';
// 2021 Census of Population characteristics
import availableFields from './fields'
let currentField = 'total_1';
let currentClassification = null;
let mapLoaded = false;
let hoveredFeatureId = null;
// Get field from URL parameters
function getFieldFromURL() {
const urlParams = new URLSearchParams(window.location.search);
const fieldParam = urlParams.get('field');
if (fieldParam && availableFields.includes(fieldParam)) {
return fieldParam;
}
return 'total_1'; // default
}
// Update URL with current field
function updateURL(field) {
const url = new URL(window.location);
url.searchParams.set('field', field);
window.history.replaceState({}, '', url);
}
// Initialize field from URL
currentField = getFieldFromURL();
document.getElementById('currentField').textContent = `Current field: ${currentField}`;
const map = new maplibregl.Map({
container: 'map',
style: "https://tiles.openfreemap.org/styles/liberty",
zoom: 10,
center: [-75.695000, 45.424721],
hash: true,
maxZoom: 18,
attributionControl: false,
dragRotate: false,
keyboard: false,
pitchWithRotate: false
});
map.on('style.load', () => {
map.setProjection({
type: 'globe',
});
});
map.on('load', () => {
mapLoaded = true;
map.addSource('my-vector-tiles', {
type: 'vector',
tiles: ['https://tiles.diegoripley.ca/files/census_of_population_vector_tiles_subset_august_12_2025/da_2021_cop/{z}/{x}/{y}.mvt'],
minzoom: 8,
maxzoom: 14,
promoteId: 'da_dguid' // Promote DGUID to feature id for hover state
});
// Add controls
map.addControl(new maplibregl.FullscreenControl(), 'top-left');
// Add Pelias Geocoder
const geocoder = new PeliasGeocoder({
params: {
'boundary.country': 'CAN',
'boundary.rect.min_lat': 40,
'boundary.rect.max_lat': 60,
'boundary.rect.min_lon': -140,
'boundary.rect.max_lon': -50
},
flyTo: {
duration: 100,
curve: 1.5
},
marker: {
icon: 'marker',
color: '#FF0000'
},
placeholder: 'Search for places...'
});
map.addControl(geocoder, 'top-left');
// Disable rotation
map.touchZoomRotate.disableRotation();
// Add main visualization layer
map.addLayer({
'id': 'my-layer',
'type': 'fill',
'source': 'my-vector-tiles',
'source-layer': 'da_2021_cop',
'paint': {
'fill-color': '#cccccc',
'fill-opacity': [
'case',
['boolean', ['feature-state', 'hover'], false],
0,
0.7
]
}
});
// Add hover outline layer
map.addLayer({
'id': 'my-layer-hover',
'type': 'line',
'source': 'my-vector-tiles',
'source-layer': 'da_2021_cop',
'paint': {
'line-color': '#ff0000',
'line-width': [
'case',
['boolean', ['feature-state', 'hover'], false],
3,
0
],
'line-dasharray': [2, 2],
'line-opacity': [
'case',
['boolean', ['feature-state', 'hover'], false],
1,
0
]
},
'layout': {
'line-cap': 'round',
'line-join': 'round'
}
});
// Add static outline layer (always visible)
map.addLayer({
'id': 'my-layer-outline',
'type': 'line',
'source': 'my-vector-tiles',
'source-layer': 'da_2021_cop',
'paint': {
'line-color': '#000',
'line-width': 0.5,
'line-opacity': 0.5
}
});
// Use CKMeans by default
const actualFieldName = `count_${currentField}`;
addVisualizationLayerWithCKMeans(actualFieldName);
// Set up hover effects
setupHoverEffects();
});
function setupHoverEffects() {
// Mouse move handler for hover state
map.on('mousemove', 'my-layer', (e) => {
if (e.features.length > 0) {
// Remove previous hover state
if (hoveredFeatureId !== null) {
map.setFeatureState(
{ source: 'my-vector-tiles', sourceLayer: 'da_2021_cop', id: hoveredFeatureId },
{ hover: false }
);
}
// Set new hover state
hoveredFeatureId = e.features[0].properties.da_dguid;
if (hoveredFeatureId) {
map.setFeatureState(
{ source: 'my-vector-tiles', sourceLayer: 'da_2021_cop', id: hoveredFeatureId },
{ hover: true }
);
}
map.getCanvas().style.cursor = 'pointer';
}
});
// Mouse leave handler
map.on('mouseleave', 'my-layer', () => {
if (hoveredFeatureId !== null) {
map.setFeatureState(
{ source: 'my-vector-tiles', sourceLayer: 'da_2021_cop', id: hoveredFeatureId },
{ hover: false }
);
hoveredFeatureId = null;
}
map.getCanvas().style.cursor = '';
});
}
// Search functionality
const searchInput = document.getElementById('fieldSearch');
const searchResults = document.getElementById('searchResults');
const currentFieldDiv = document.getElementById('currentField');
const recalculateBtn = document.getElementById('recalculateBtn');
const classificationInfo = document.getElementById('classificationInfo');
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
if (query.length === 0) {
searchResults.style.display = 'none';
return;
}
const filtered = availableFields.filter(field =>
field.toLowerCase().includes(query)
).slice(0, 5);
if (filtered.length === 0) {
searchResults.innerHTML = '<div class="search-item">No fields found</div>';
} else {
searchResults.innerHTML = filtered.map(field =>
`<div class="search-item" data-field="${field}">${field}</div>`
).join('');
}
searchResults.style.display = 'block';
});
searchResults.addEventListener('click', (e) => {
if (e.target.classList.contains('search-item')) {
const selectedField = e.target.getAttribute('data-field');
if (selectedField && selectedField !== currentField) {
currentField = selectedField;
currentFieldDiv.textContent = `Current field: ${currentField}`;
searchInput.value = '';
searchResults.style.display = 'none';
updateURL(currentField);
addVisualizationLayerWithCKMeans(currentField);
}
}
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-container')) {
searchResults.style.display = 'none';
}
});
recalculateBtn.addEventListener('click', () => {
recalculateClassesFromExtent();
});
function calculateCKMeansBreaks(features, field, numClasses = 5) {
const values = features
.map(f => f.properties[field])
.filter(v => v !== null && v !== undefined && !isNaN(v));
if (values.length === 0) return null;
const uniqueValues = [...new Set(values)].sort((a, b) => a - b);
const actualNumClasses = Math.min(numClasses, uniqueValues.length);
if (actualNumClasses === 1) {
return {
breaks: [uniqueValues[0], uniqueValues[0]],
colors: ['#ff0000'],
method: 'single-value',
numClasses: 1
};
}
try {
const clusters = ss.ckmeans(values, actualNumClasses);
const breaks = [];
for (let i = 0; i < clusters.length; i++) {
if (i === 0) {
breaks.push(Math.min(...clusters[i]));
}
breaks.push(Math.max(...clusters[i]));
}
const colors = generateColors(actualNumClasses);
return {
breaks: breaks,
colors: colors,
method: 'ckmeans',
numClasses: actualNumClasses,
clusters: clusters.length
};
} catch (error) {
console.warn('CKMeans failed, falling back to quantile classification:', error);
return calculateQuantileBreaks(values, actualNumClasses);
}
}
function calculateQuantileBreaks(values, numClasses) {
const sortedValues = [...values].sort((a, b) => a - b);
const breaks = [];
const colors = generateColors(numClasses);
for (let i = 0; i <= numClasses; i++) {
const quantile = i / numClasses;
const index = Math.floor(quantile * (sortedValues.length - 1));
breaks.push(sortedValues[index]);
}
return {
breaks: breaks,
colors: colors,
method: 'quantile-fallback',
numClasses: numClasses
};
}
function generateColors(numClasses) {
const colors = [];
for (let i = 0; i < numClasses; i++) {
const intensity = (i + 1) / numClasses;
const red = Math.round(255);
const green = Math.round(255 * (1 - intensity));
const blue = Math.round(255 * (1 - intensity));
colors.push(`rgb(${red}, ${green}, ${blue})`);
}
return colors;
}
function addVisualizationLayerWithCKMeans(field) {
if (!mapLoaded) return;
// Update temporary color
map.setPaintProperty('my-layer', 'fill-color', '#cccccc');
updateClassificationInfo('Loading CKMeans...', 'calculating optimal classes');
setTimeout(() => {
recalculateClassesFromExtent();
}, 500);
}
function recalculateClassesFromExtent() {
if (!mapLoaded) return;
const features = map.queryRenderedFeatures({ layers: ['my-layer'] });
if (features.length > 0) {
console.log(`Calculating CKMeans classification for ${features.length} features`);
const actualFieldName = `count_${currentField}`;
const classification = calculateCKMeansBreaks(features, actualFieldName, 5);
if (classification) {
currentClassification = classification;
const paintExpression = ['case'];
for (let i = 0; i < classification.breaks.length - 1; i++) {
const lowerBound = classification.breaks[i];
const upperBound = classification.breaks[i + 1];
if (i === 0) {
paintExpression.push(['<=', ['get', actualFieldName], upperBound]);
} else {
paintExpression.push([
'all',
['>', ['get', actualFieldName], lowerBound],
['<=', ['get', actualFieldName], upperBound]
]);
}
paintExpression.push(classification.colors[i]);
}
paintExpression.push('#cccccc');
map.setPaintProperty('my-layer', 'fill-color', paintExpression);
updateLegend(currentField, classification.breaks, classification.colors);
updateClassificationInfo(
`${classification.method} (${classification.numClasses} classes)`,
`${features.length} features analyzed`
);
console.log('Classification applied:', classification);
}
} else {
console.log('No features in current view, trying to get data for initial classification...');
updateClassificationInfo('CKMeans (5 classes)', 'Pan/zoom to data areas for classification');
}
}
function updateLegend(field, breaks, colors) {
const legendContent = document.getElementById('legendContent');
let legendHTML = '';
for (let i = 0; i < colors.length && i < breaks.length - 1; i++) {
const rangeStart = breaks[i];
const rangeEnd = breaks[i + 1];
let label;
if (i === 0) {
label = `${rangeEnd.toFixed(0)}`;
} else {
label = `${rangeStart.toFixed(0)} - ${rangeEnd.toFixed(0)}`;
}
legendHTML += `
<div class="legend-item">
<div class="legend-color" style="background-color: ${colors[i]}"></div>
<span>${label}</span>
</div>
`;
}
legendContent.innerHTML = legendHTML;
}
function updateClassificationInfo(method, details) {
classificationInfo.innerHTML = `Classification: ${method}<br><small>${details}</small>`;
}
window.addEventListener('popstate', (e) => {
const newField = getFieldFromURL();
if (newField !== currentField && availableFields.includes(newField)) {
currentField = newField;
document.getElementById('currentField').textContent = `Current field: ${currentField}`;
addVisualizationLayerWithCKMeans(currentField);
}
});
map.on('click', 'my-layer', (e) => {
if (e.features.length > 0) {
const feature = e.features[0];
const properties = feature.properties;
console.log(`Field: ${currentField}, Value: ${properties[`count_${currentField}`]}`);
console.log('DGUID:', properties.da_dguid);
const clickLatLong = e.lngLat.wrap();
const googleMapsURL = `https://www.google.ca/maps/@${clickLatLong.lat},${clickLatLong.lng},17z`;
const openstreetmapURL = `https://www.openstreetmap.org/#map=17/${clickLatLong.lat}/${clickLatLong.lng}`;
const bingURL = `https://www.bing.com/maps?FORM=Z9LH2&cp=${clickLatLong.lat}%7E${clickLatLong.lng}&lvl=16.0`;
new maplibregl.Popup()
.setLngLat(e.lngLat)
.setHTML(`
<div style="font-size: 12px;">
<strong>${currentField}:</strong> ${properties[`count_${currentField}`]}<br>
<strong>DGUID:</strong> ${properties.da_dguid}<br>
<strong>Google Maps:</strong> <a href="${googleMapsURL}" target="_blank">Open</a><br>
<strong>OpenStreetMap:</strong> <a href="${openstreetmapURL}" target="_blank">Open</a><br>
<strong>Bing:</strong> <a href="${bingURL}" target="_blank">Open</a>
</div>
`)
.addTo(map);
}
});
@@ -0,0 +1,313 @@
/**
* Pelias Geocoder for MapLibre GL JS
* Adapted from pelias-mapbox-gl-js for MapLibre compatibility
*/
import maplibregl from 'maplibre-gl';
class PeliasGeocoder {
constructor(options = {}) {
this.options = {
url: options.url || 'https://geocoder.alpha.phac.gc.ca/api/v1',
apiKey: options.apiKey || '',
params: options.params || {},
flyTo: options.flyTo === false ? false : (options.flyTo || {}),
wof: options.wof === false ? false : true,
marker: options.marker === false ? false : (options.marker || {}),
placeholder: options.placeholder || 'Search',
minLength: options.minLength || 3,
limit: options.limit || 5,
customAttribution: options.customAttribution || null
};
this.marker = null;
this.results = [];
this.selectedIndex = -1;
this.lastQuery = '';
this.searchTimeout = null;
}
onAdd(map) {
this.map = map;
this.container = document.createElement('div');
this.container.className = 'maplibregl-ctrl pelias-ctrl';
// Create input
this.input = document.createElement('input');
this.input.type = 'text';
this.input.className = 'pelias-ctrl-input';
this.input.placeholder = this.options.placeholder;
// Create clear button
this.clearBtn = document.createElement('button');
this.clearBtn.className = 'pelias-ctrl-clear';
this.clearBtn.innerHTML = '×';
this.clearBtn.style.display = 'none';
// Create results container
this.resultsContainer = document.createElement('div');
this.resultsContainer.className = 'pelias-ctrl-results';
// Assemble the control
this.container.appendChild(this.input);
this.container.appendChild(this.clearBtn);
this.container.appendChild(this.resultsContainer);
// Set up event listeners
this.setupEventListeners();
return this.container;
}
onRemove() {
if (this.marker) {
this.marker.remove();
}
this.container.parentNode.removeChild(this.container);
this.map = undefined;
}
setupEventListeners() {
// Input events
this.input.addEventListener('input', (e) => {
const query = e.target.value;
if (query.length >= this.options.minLength) {
this.clearBtn.style.display = 'block';
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.search(query);
}, 300);
} else {
this.clearBtn.style.display = 'none';
this.clearResults();
}
});
// Keyboard navigation
this.input.addEventListener('keydown', (e) => {
if (!this.results.length) return;
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
this.selectResult(Math.min(this.selectedIndex + 1, this.results.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
this.selectResult(Math.max(this.selectedIndex - 1, -1));
break;
case 'Enter':
e.preventDefault();
if (this.selectedIndex >= 0) {
this.chooseResult(this.results[this.selectedIndex]);
}
break;
case 'Escape':
this.clearResults();
this.input.blur();
break;
}
});
// Clear button
this.clearBtn.addEventListener('click', () => {
this.input.value = '';
this.clearBtn.style.display = 'none';
this.clearResults();
if (this.marker) {
this.marker.remove();
this.marker = null;
}
this.input.focus();
});
// Click outside to close
document.addEventListener('click', (e) => {
if (!this.container.contains(e.target)) {
this.clearResults();
}
});
}
async search(query) {
if (query === this.lastQuery) return;
this.lastQuery = query;
const params = new URLSearchParams({
text: query,
size: this.options.limit,
...this.options.params
});
if (this.options.apiKey) {
params.append('api_key', this.options.apiKey);
}
// Add focus point if map has a center
if (this.map) {
const center = this.map.getCenter();
params.append('focus.point.lat', center.lat);
params.append('focus.point.lon', center.lng);
}
try {
const response = await fetch(`${this.options.url}/search?${params}`);
const data = await response.json();
if (data.features) {
this.results = data.features;
this.displayResults();
}
} catch (error) {
console.error('Geocoding error:', error);
this.showError('Search failed. Please try again.');
}
}
displayResults() {
this.resultsContainer.innerHTML = '';
this.selectedIndex = -1;
if (this.results.length === 0) {
const noResults = document.createElement('div');
noResults.className = 'pelias-ctrl-result';
noResults.textContent = 'No results found';
this.resultsContainer.appendChild(noResults);
} else {
this.results.forEach((result, index) => {
const item = document.createElement('div');
item.className = 'pelias-ctrl-result';
const name = document.createElement('div');
name.className = 'pelias-ctrl-result-name';
name.textContent = result.properties.name || result.properties.label;
const address = document.createElement('div');
address.className = 'pelias-ctrl-result-address';
// Build address from properties
const parts = [];
if (result.properties.locality) parts.push(result.properties.locality);
if (result.properties.region) parts.push(result.properties.region);
if (result.properties.country) parts.push(result.properties.country);
address.textContent = parts.join(', ');
item.appendChild(name);
if (address.textContent) {
item.appendChild(address);
}
item.addEventListener('click', () => {
this.chooseResult(result);
});
item.addEventListener('mouseenter', () => {
this.selectResult(index);
});
this.resultsContainer.appendChild(item);
});
}
this.resultsContainer.classList.add('active');
}
selectResult(index) {
// Remove previous selection
const items = this.resultsContainer.querySelectorAll('.pelias-ctrl-result');
items.forEach((item, i) => {
if (i === index) {
item.classList.add('active');
} else {
item.classList.remove('active');
}
});
this.selectedIndex = index;
}
chooseResult(result) {
// Update input
this.input.value = result.properties.label || result.properties.name;
// Clear results
this.clearResults();
// Get coordinates
const coords = result.geometry.coordinates;
// Fly to location
if (this.options.flyTo !== false && this.map) {
const flyOptions = {
center: coords,
zoom: 16,
...this.options.flyTo
};
this.map.flyTo(flyOptions);
}
// Add/update marker
if (this.options.marker !== false && this.map) {
if (this.marker) {
this.marker.setLngLat(coords);
} else {
const markerOptions = {
color: '#FF0000',
...this.options.marker
};
this.marker = new maplibregl.Marker(markerOptions)
.setLngLat(coords)
.addTo(this.map);
}
}
// Trigger custom event
this.container.dispatchEvent(new CustomEvent('select', {
detail: result
}));
}
clearResults() {
this.resultsContainer.innerHTML = '';
this.resultsContainer.classList.remove('active');
this.results = [];
this.selectedIndex = -1;
}
showError(message) {
this.resultsContainer.innerHTML = '';
const error = document.createElement('div');
error.className = 'pelias-ctrl-error';
error.textContent = message;
this.resultsContainer.appendChild(error);
this.resultsContainer.classList.add('active');
}
// Public methods
setQuery(query) {
this.input.value = query;
if (query.length >= this.options.minLength) {
this.search(query);
}
}
clear() {
this.input.value = '';
this.clearBtn.style.display = 'none';
this.clearResults();
if (this.marker) {
this.marker.remove();
this.marker = null;
}
}
focus() {
this.input.focus();
}
blur() {
this.input.blur();
}
}
export default PeliasGeocoder;
@@ -0,0 +1,256 @@
@import 'maplibre-gl/dist/maplibre-gl.css';
body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
}
html,
body,
#map {
height: 100%;
}
.search-container {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
width: 300px;
}
.search-input {
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 14px;
outline: none;
box-sizing: border-box;
}
.search-results {
max-height: 200px;
overflow-y: auto;
border-top: 1px solid #eee;
display: none;
}
.search-item {
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid #f5f5f5;
font-size: 13px;
}
.search-item:hover {
background-color: #f0f0f0;
}
.search-item:last-child {
border-bottom: none;
}
.recalculate-btn {
position: absolute;
bottom: 10px;
left: 10px;
z-index: 1000;
background: #007cbf;
color: white;
border: none;
padding: 10px 15px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.recalculate-btn:hover {
background: #005a87;
}
.current-field {
position: absolute;
top: 70px;
right: 10px;
z-index: 1000;
background: rgba(255, 255, 255, 0.95);
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
color: #333;
max-width: 300px;
word-break: break-word;
}
.legend {
position: absolute;
bottom: 20px;
right: 10px;
z-index: 1000;
background: rgba(255, 255, 255, 0.95);
border-radius: 6px;
padding: 12px;
font-size: 12px;
min-width: 120px;
}
.legend-title {
font-weight: bold;
margin-bottom: 8px;
}
.legend-item {
display: flex;
align-items: center;
margin-bottom: 4px;
}
.legend-color {
width: 16px;
height: 16px;
margin-right: 8px;
border: 1px solid #ccc;
}
.classification-info {
position: absolute;
bottom: 50px;
left: 10px;
z-index: 1000;
background: rgba(255, 255, 255, 0.95);
padding: 8px 12px;
border-radius: 6px;
font-size: 11px;
color: #666;
max-width: 200px;
}
/* Pelias Geocoder Styles - Adapted for MapLibre */
.pelias-ctrl {
position: relative;
display: inline-block;
background: #fff;
border-radius: 3px;
box-shadow: 0 0 10px 2px rgba(0, 0, 0, .1);
min-width: 200px;
max-width: 360px;
}
.pelias-ctrl-input {
width: 100%;
border: 0;
font-size: 12px;
line-height: 1.67;
padding: 8px 32px 8px 8px;
text-overflow: ellipsis;
background: transparent;
outline: none;
box-sizing: border-box;
}
.pelias-ctrl-input::-webkit-input-placeholder {
color: #9b9b9b;
}
.pelias-ctrl-input::-moz-placeholder {
color: #9b9b9b;
}
.pelias-ctrl-input:-ms-input-placeholder {
color: #9b9b9b;
}
.pelias-ctrl-input:-moz-placeholder {
color: #9b9b9b;
}
.pelias-ctrl-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #fff;
border-top: 1px solid #eee;
border-radius: 0 0 3px 3px;
box-shadow: 0 0 10px 2px rgba(0, 0, 0, .1);
z-index: 1000;
max-height: 200px;
overflow-y: auto;
display: none;
}
.pelias-ctrl-results.active {
display: block;
}
.pelias-ctrl-result {
padding: 8px;
cursor: pointer;
border-bottom: 1px solid #eee;
font-size: 12px;
line-height: 1.4;
}
.pelias-ctrl-result:last-child {
border-bottom: none;
}
.pelias-ctrl-result:hover,
.pelias-ctrl-result.active {
background: #f8f8f8;
}
.pelias-ctrl-result-name {
font-weight: bold;
margin-bottom: 2px;
}
.pelias-ctrl-result-address {
color: #666;
}
.pelias-ctrl-clear {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: #999;
padding: 0;
width: 20px;
height: 20px;
display: none;
}
.pelias-ctrl-clear:hover {
color: #333;
}
.pelias-ctrl-clear.active {
display: block;
}
.pelias-ctrl-error {
padding: 8px;
color: #d00;
font-size: 11px;
border-top: 1px solid #eee;
}
/* MapLibre Control positioning */
.maplibregl-ctrl-top-right .pelias-ctrl {
margin: 10px 10px 0 0;
}
.maplibregl-ctrl-top-left .pelias-ctrl {
margin: 10px 0 0 10px;
}