mirror of
https://github.com/dataforcanada/d4c-datapkg-statistical.git
synced 2026-06-13 22:20:56 +02:00
Add code that was used for https://www.diegoripley.ca/files/census_of_population_vector_tiles_subset_august_12_2025/
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user