Move static outside HTML. Start work on TypeScript bundle
It's not strictly HTML, so that's just correct. This is just worth doing while building the new TypeScript bundle
This commit is contained in:
parent
976a29b7d7
commit
31947c848a
53 changed files with 7100 additions and 73 deletions
131
static/js/address-display.js
Normal file
131
static/js/address-display.js
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
class AddressDisplay extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Create a shadow DOM
|
||||
this.attachShadow({ mode: "open" });
|
||||
|
||||
// Initial render
|
||||
this.render();
|
||||
|
||||
// Element references
|
||||
this._locationDisplay = this.shadowRoot.querySelector(".location-display");
|
||||
this._streetAddress = this.shadowRoot.querySelector(".street-address");
|
||||
this._postCode = this.shadowRoot.querySelector(".post-code");
|
||||
this._district = this.shadowRoot.querySelector(".district");
|
||||
this._region = this.shadowRoot.querySelector(".region");
|
||||
this._country = this.shadowRoot.querySelector(".country");
|
||||
}
|
||||
|
||||
// Initial render of component
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
@import url('/static/vendor/css/bootstrap.min.css');
|
||||
.detail-label {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
color: #6c757d;
|
||||
margin-bottom: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.detail-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
.main-address {
|
||||
font-weight: 500;
|
||||
}
|
||||
.place-info {
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.suggestions-container {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.suggestion-item {
|
||||
cursor: pointer;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.suggestion-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="location-display" class="mt-4 d-none">
|
||||
<h5 class="mb-3">Location Details</h5>
|
||||
<div class="location-card p-3 mb-3">
|
||||
<div class="location-detail">
|
||||
<div class="detail-label">Street Address</div>
|
||||
<div class="street-address detail-value">-</div>
|
||||
</div>
|
||||
|
||||
<div class="location-detail">
|
||||
<div class="detail-label">Post Code</div>
|
||||
<div class="post-code detail-value">-</div>
|
||||
</div>
|
||||
|
||||
<div class="location-detail">
|
||||
<div class="detail-label">District/Place</div>
|
||||
<div class="district detail-value">-</div>
|
||||
</div>
|
||||
|
||||
<div class="location-detail">
|
||||
<div class="detail-label">Region/State</div>
|
||||
<div class="region detail-value">-</div>
|
||||
</div>
|
||||
|
||||
<div class="location-detail">
|
||||
<div class="detail-label">Country</div>
|
||||
<div class="country detail-value">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Public methods
|
||||
show(location) {
|
||||
console.log("Showing location", location);
|
||||
// Extract context data from properties
|
||||
const props = location.properties;
|
||||
const context = props.context || {};
|
||||
|
||||
// Populate structured fields
|
||||
// Street Address - combine address, street, housenumber if available
|
||||
let addressStr = "";
|
||||
if (context.address) addressStr += context.address.address_number;
|
||||
if (context.street) {
|
||||
if (addressStr) addressStr += " ";
|
||||
addressStr += context.street.name;
|
||||
}
|
||||
if (addressStr === "") {
|
||||
addressStr = props.name || props.full_address || "-";
|
||||
}
|
||||
this._streetAddress.textContent = addressStr;
|
||||
|
||||
// Post Code
|
||||
this._postCode.textContent = context.postcode.name || "-";
|
||||
|
||||
// District (could be district, locality, or place)
|
||||
this._district.textContent =
|
||||
context.district.name ||
|
||||
context.place.name ||
|
||||
context.locality.name ||
|
||||
"-";
|
||||
|
||||
// Region (state, province, etc.)
|
||||
this._region.textContent = context.region.name || "-";
|
||||
|
||||
// Country
|
||||
this._country.textContent = context.country.name || "-";
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("address-display", AddressDisplay);
|
||||
273
static/js/address-or-report-suggestion.js
Normal file
273
static/js/address-or-report-suggestion.js
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
class AddressOrReportInput extends HTMLElement {
|
||||
// make element form-associated
|
||||
static formAssociated = true;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.internals = this.attachInternals();
|
||||
this.render();
|
||||
|
||||
// Element references
|
||||
this._addresses = [];
|
||||
this._input = this.shadowRoot.querySelector("input");
|
||||
this._reports = [];
|
||||
this._suggestionsContainer = this.shadowRoot.querySelector(
|
||||
".suggestions-container",
|
||||
);
|
||||
|
||||
// Bind methods
|
||||
this._handleInput = this._handleInput.bind(this);
|
||||
|
||||
// Debounce timer
|
||||
this._debounceTimer = null;
|
||||
|
||||
// The suggestion data
|
||||
this._suggestionData = null;
|
||||
}
|
||||
|
||||
// Lifecycle: when element is added to the DOM
|
||||
connectedCallback() {
|
||||
this._input.addEventListener("input", this._handleInput);
|
||||
}
|
||||
|
||||
// Lifecycle: when element is removed from the DOM
|
||||
disconnectedCallback() {
|
||||
this._input.removeEventListener("input", this._handleInput);
|
||||
}
|
||||
|
||||
// Lifecycle: watch these attributes for changes
|
||||
static get observedAttributes() {
|
||||
return ["placeholder", "api-key"];
|
||||
}
|
||||
|
||||
// Lifecycle: respond to attribute changes
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
if (name === "placeholder" && this._input) {
|
||||
this._input.placeholder = newValue;
|
||||
}
|
||||
|
||||
if (name === "api-key") {
|
||||
this._apiKey = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Properties API
|
||||
get value() {
|
||||
return this._input ? this._input.value : "";
|
||||
}
|
||||
|
||||
set value(val) {
|
||||
if (this._input) {
|
||||
this._input.value = val;
|
||||
const entries = new FormData();
|
||||
entries.append("address", val);
|
||||
this.internals.setFormValue(entries);
|
||||
}
|
||||
}
|
||||
|
||||
// Private methods
|
||||
_handleInput(event) {
|
||||
const searchText = event.target.value.trim();
|
||||
|
||||
// Clear previous timer
|
||||
clearTimeout(this._debounceTimer);
|
||||
|
||||
// Clear suggestions if input is less than 3 characters
|
||||
if (searchText.length < 3) {
|
||||
this._suggestionsContainer.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce API calls (wait 300ms after typing stops)
|
||||
this._debounceTimer = setTimeout(() => {
|
||||
this._handleSuggestions(searchText);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async _fetchAddressSuggestions(text) {
|
||||
try {
|
||||
const url = `https://api.stadiamaps.com/geocoding/v2/autocomplete?text=${encodeURIComponent(text)}&focus.point.lat=35&focus.point.lon=-115`;
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
return data.features || [];
|
||||
} catch (error) {
|
||||
console.error("Error fetching geocoding suggestions:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async _fetchReportSuggestions(text) {
|
||||
try {
|
||||
const url = `/report/suggest?r=${text}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
return data.reports || [];
|
||||
} catch (error) {
|
||||
console.error("Error fetching report suggestions:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async _handleClick(el) {
|
||||
const type = el.dataset.type;
|
||||
let content = null;
|
||||
if (type == "report") {
|
||||
const index = parseInt(el.dataset.index);
|
||||
content = this._reports[index];
|
||||
this.value = _formatReportID(content.id);
|
||||
this._suggestionsContainer.innerHTML = "";
|
||||
} else if (type == "address") {
|
||||
const gid = el.dataset.gid;
|
||||
const url = `https://api.stadiamaps.com/geocoding/v2/place_details?ids=${gid}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
content = data.features[0];
|
||||
this.SetValue(content);
|
||||
}
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("suggestion-selected", {
|
||||
bubbles: true,
|
||||
composed: true, // Allows event to cross shadow DOM boundary
|
||||
detail: {
|
||||
content: content,
|
||||
type: type,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
async _handleSuggestions(text) {
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
this._addresses = await this._fetchAddressSuggestions(text);
|
||||
})(),
|
||||
(async () => {
|
||||
this._reports = await this._fetchReportSuggestions(text);
|
||||
})(),
|
||||
]);
|
||||
this._renderSuggestions(this._addresses, this._reports);
|
||||
}
|
||||
|
||||
_renderSuggestions(addresses, reports) {
|
||||
console.log("Rendering suggestions", addresses, reports);
|
||||
const reportElements = reports
|
||||
.map((item, index) => {
|
||||
const formatted_id = _formatReportID(item.id);
|
||||
const type_display = _formatReportType(item.type);
|
||||
return `
|
||||
<div class="suggestion-item list-group-item"
|
||||
data-index="${index}"
|
||||
data-report-id="${item.id}"
|
||||
data-type="report">
|
||||
<div class="report-id">${formatted_id}</div>
|
||||
<div class="report-type">${type_display}</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
const addressElements = addresses
|
||||
.map((item, index) => {
|
||||
return `
|
||||
<div class="suggestion-item list-group-item"
|
||||
data-gid="${item.properties.gid}"
|
||||
data-type="address">
|
||||
<div class="main-address">${item.properties.name}</div>
|
||||
<div class="place-info">${item.properties.coarse_location}</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
this._suggestionsContainer.innerHTML = reportElements + addressElements;
|
||||
// Add click listeners to suggestions
|
||||
this.shadowRoot.querySelectorAll(".suggestion-item").forEach((el) => {
|
||||
el.addEventListener("click", (e) => {
|
||||
this._handleClick(el);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initial render of component
|
||||
render() {
|
||||
const placeholder = this.getAttribute("placeholder") || "Enter address";
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
@import url('/static/css/bootstrap.css');
|
||||
@import url('/static/vendor/css/bootstrap-icons.min.css');
|
||||
.detail-label {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
color: #6c757d;
|
||||
margin-bottom: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.detail-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
.main-address {
|
||||
font-weight: 500;
|
||||
}
|
||||
.place-info {
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.suggestions-container {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
top: 48px;
|
||||
}
|
||||
.suggestion-item {
|
||||
cursor: pointer;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.suggestion-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control form-control-lg" id="addressSearch" maxlength="200" name="address" placeholder="${placeholder}">
|
||||
<div id="suggestions" class="suggestions-container list-group"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Public methods
|
||||
clear() {
|
||||
if (this._input) {
|
||||
this._input.value = "";
|
||||
this._suggestionsContainer.innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
SetValue(suggestion) {
|
||||
this.value = suggestion.properties.formatted_address_line;
|
||||
this._suggestionsContainer.innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
function _formatReportID(id) {
|
||||
if (id.length === 12) {
|
||||
return `${id.substring(0, 4)}-${id.substring(4, 8)}-${id.substring(8)}`;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function _formatReportType(type) {
|
||||
if (type == "nuisance") {
|
||||
return "Mosquito Nuisance Report";
|
||||
} else if (type == "water") {
|
||||
return "Standing Water Report";
|
||||
} else {
|
||||
return "Unknown Report Type";
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("address-or-report-input", AddressOrReportInput);
|
||||
209
static/js/address-suggestion.js
Normal file
209
static/js/address-suggestion.js
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
class AddressInput extends HTMLElement {
|
||||
// make element form-associated
|
||||
static formAssociated = true;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.internals = this.attachInternals();
|
||||
this.render();
|
||||
|
||||
// Element references
|
||||
this._input = this.shadowRoot.querySelector("input");
|
||||
this._suggestions = this.shadowRoot.querySelector(".suggestions-container");
|
||||
|
||||
// Bind methods
|
||||
this._handleInput = this._handleInput.bind(this);
|
||||
|
||||
// Debounce timer
|
||||
this._debounceTimer = null;
|
||||
|
||||
// The suggestion data
|
||||
this._suggestionData = null;
|
||||
}
|
||||
|
||||
// Lifecycle: when element is added to the DOM
|
||||
connectedCallback() {
|
||||
this._input.addEventListener("input", this._handleInput);
|
||||
}
|
||||
|
||||
// Lifecycle: when element is removed from the DOM
|
||||
disconnectedCallback() {
|
||||
this._input.removeEventListener("input", this._handleInput);
|
||||
}
|
||||
|
||||
// Lifecycle: watch these attributes for changes
|
||||
static get observedAttributes() {
|
||||
return ["placeholder", "api-key"];
|
||||
}
|
||||
|
||||
// Lifecycle: respond to attribute changes
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
if (name === "placeholder" && this._input) {
|
||||
this._input.placeholder = newValue;
|
||||
}
|
||||
|
||||
if (name === "api-key") {
|
||||
this._apiKey = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Properties API
|
||||
get value() {
|
||||
return this._input ? this._input.value : "";
|
||||
}
|
||||
|
||||
set value(val) {
|
||||
if (this._input) {
|
||||
this._input.value = val;
|
||||
const entries = new FormData();
|
||||
entries.append("address", val);
|
||||
this.internals.setFormValue(entries);
|
||||
}
|
||||
}
|
||||
|
||||
// Private methods
|
||||
_handleInput(event) {
|
||||
const searchText = event.target.value.trim();
|
||||
|
||||
// Set the form input value if they submit the form without choosing an option
|
||||
this.value = event.target.value;
|
||||
|
||||
// Clear previous timer
|
||||
clearTimeout(this._debounceTimer);
|
||||
|
||||
// Clear suggestions if input is less than 3 characters
|
||||
if (searchText.length < 3) {
|
||||
this._suggestions.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce API calls (wait 300ms after typing stops)
|
||||
this._debounceTimer = setTimeout(() => {
|
||||
this._fetchAddressSuggestions(searchText).then((response) => {
|
||||
this._renderSuggestions(response.features);
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
async _handleClick(gid) {
|
||||
try {
|
||||
const url = `https://api.stadiamaps.com/geocoding/v2/place_details?ids=${gid}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
const suggestion = data.features[0];
|
||||
this.SetValue(suggestion);
|
||||
|
||||
// Dispatch custom event for clients of this library
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("address-selected", {
|
||||
bubbles: true,
|
||||
composed: true, // Allows event to cross shadow DOM boundary
|
||||
detail: {
|
||||
location: suggestion,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error fetching geocode of suggestion:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async _fetchAddressSuggestions(text) {
|
||||
try {
|
||||
//const url = `https://api.mapbox.com/search/geocode/v6/forward?q=${encodeURIComponent(text)}&access_token=${this._apiKey}`;
|
||||
const url = `https://api.stadiamaps.com/geocoding/v2/autocomplete?text=${encodeURIComponent(text)}&focus.point.lat=35&focus.point.lon=-115`;
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching geocoding suggestions:", error);
|
||||
}
|
||||
}
|
||||
|
||||
_renderSuggestions(suggestions) {
|
||||
console.log("Rendering suggestions", suggestions);
|
||||
this._suggestions.innerHTML = suggestions
|
||||
.map((item, index) => {
|
||||
return `
|
||||
<div class="suggestion-item list-group-item"
|
||||
data-gid="${item.properties.gid}">
|
||||
<div class="main-address">${item.properties.name}</div>
|
||||
<div class="place-info">${item.properties.coarse_location}</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// Add click listeners to suggestions
|
||||
this.shadowRoot.querySelectorAll(".suggestion-item").forEach((el) => {
|
||||
el.addEventListener("click", (e) => {
|
||||
this._handleClick(el.dataset.gid);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initial render of component
|
||||
render() {
|
||||
const placeholder = this.getAttribute("placeholder") || "Enter address";
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
@import url('/static/vendor/css/bootstrap.min.css');
|
||||
.detail-label {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
color: #6c757d;
|
||||
margin-bottom: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.detail-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
.main-address {
|
||||
font-weight: 500;
|
||||
}
|
||||
.place-info {
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.suggestions-container {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.suggestion-item {
|
||||
cursor: pointer;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.suggestion-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
</style>
|
||||
|
||||
<label for="addressInput" class="form-label">Enter address</label>
|
||||
<input class="form-control" id="address" maxlength="200" name="address" placeholder="${placeholder}" type="text">
|
||||
<div id="suggestions" class="suggestions-container list-group"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Public methods
|
||||
clear() {
|
||||
if (this._input) {
|
||||
this._input.value = "";
|
||||
this._suggestions.innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
SetValue(suggestion) {
|
||||
this.value = suggestion.properties.formatted_address_line;
|
||||
this._suggestions.innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("address-input", AddressInput);
|
||||
3018
static/js/alpine-3.15.8-min.js
vendored
Normal file
3018
static/js/alpine-3.15.8-min.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
127
static/js/events.js
Normal file
127
static/js/events.js
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// sse-manager.js - Include this in your common template
|
||||
window.SSEManager = (function () {
|
||||
let eventSource = null;
|
||||
let subscribers = new Map();
|
||||
let isConnected = false;
|
||||
let connectionPromise = null;
|
||||
|
||||
function subscribe(eventType, handler) {
|
||||
if (!subscribers.has(eventType)) {
|
||||
subscribers.set(eventType, []);
|
||||
}
|
||||
subscribers.get(eventType).push(handler);
|
||||
|
||||
// If already connected, attach the listener immediately
|
||||
if (isConnected && eventSource) {
|
||||
eventSource.addEventListener(eventType, handler);
|
||||
}
|
||||
}
|
||||
|
||||
function unsubscribe(eventType, handler) {
|
||||
if (subscribers.has(eventType)) {
|
||||
const handlers = subscribers.get(eventType);
|
||||
const index = handlers.indexOf(handler);
|
||||
if (index > -1) {
|
||||
handlers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
if (eventSource) {
|
||||
eventSource.removeEventListener(eventType, handler);
|
||||
}
|
||||
}
|
||||
|
||||
function connect(url) {
|
||||
if (connectionPromise) {
|
||||
return connectionPromise;
|
||||
}
|
||||
|
||||
connectionPromise = new Promise((resolve, reject) => {
|
||||
eventSource = new EventSource(url);
|
||||
|
||||
eventSource.onopen = function () {
|
||||
isConnected = true;
|
||||
|
||||
// Attach all pre-registered handlers
|
||||
subscribers.forEach((handlers, eventType) => {
|
||||
handlers.forEach((handler) => {
|
||||
eventSource.addEventListener("message", (message) => {
|
||||
const data = JSON.parse(message.data);
|
||||
if (eventType == "*" || eventType == data.type) {
|
||||
handler(data);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
console.log("SSE connected");
|
||||
resolve(eventSource);
|
||||
};
|
||||
|
||||
eventSource.onerror = function (err) {
|
||||
console.error("SSE error:", err);
|
||||
isConnected = false;
|
||||
|
||||
// Close old connection
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
|
||||
// Reconnect after delay
|
||||
setTimeout(() => {
|
||||
connectionPromise = null;
|
||||
connect(url);
|
||||
}, 5000);
|
||||
|
||||
if (!isConnected) {
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return connectionPromise;
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
isConnected = false;
|
||||
connectionPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
function ready(callback) {
|
||||
if (connectionPromise) {
|
||||
connectionPromise.then(callback);
|
||||
} else {
|
||||
// If connect hasn't been called yet, queue it
|
||||
const checkInterval = setInterval(() => {
|
||||
if (connectionPromise) {
|
||||
clearInterval(checkInterval);
|
||||
connectionPromise.then(callback);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
ready,
|
||||
};
|
||||
})();
|
||||
|
||||
// Initialize SSE for navigation notifications
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
SSEManager.connect("/api/events");
|
||||
});
|
||||
|
||||
function updateNotificationBadge(data) {
|
||||
const badge = document.querySelector(".notification-badge");
|
||||
if (badge) {
|
||||
badge.textContent = data.count;
|
||||
badge.style.display = data.count > 0 ? "block" : "none";
|
||||
}
|
||||
}
|
||||
12
static/js/geocode.js
Normal file
12
static/js/geocode.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
async function geocodeReverse(lngLat) {
|
||||
// curl "https://api.stadiamaps.com/geocoding/v2/reverse?point.lat=59.444351&point.lon=24.750645&api_key=YOUR-API-KEY"
|
||||
const url = `https://api.stadiamaps.com/geocoding/v2/reverse?point.lat=${lngLat.lat}&point.lon=${lngLat.lng}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
console.log("reverse geocoded to", data);
|
||||
if (data.features.length == 0) {
|
||||
console.warn("No results for reverse geocode");
|
||||
return;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
23
static/js/location.js
Normal file
23
static/js/location.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
function getGeolocation(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if geolocation is supported by the browser
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error("Geolocation is not supported by your browser"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Default options if none provided
|
||||
const geolocationOptions = options || {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 5000,
|
||||
maximumAge: 0,
|
||||
};
|
||||
|
||||
// Call the geolocation API
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => resolve(position),
|
||||
(error) => reject(error),
|
||||
geolocationOptions,
|
||||
);
|
||||
});
|
||||
}
|
||||
148
static/js/map-admin.js
Normal file
148
static/js/map-admin.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// A test of maplibre-gl in a custom element
|
||||
class MapAdmin extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Create a shadow DOM
|
||||
this.attachShadow({ mode: "open" });
|
||||
|
||||
// Initial render
|
||||
this.render();
|
||||
|
||||
this._map = null;
|
||||
|
||||
// markers shown on the map
|
||||
this._markers = [];
|
||||
}
|
||||
|
||||
// Lifecycle: when element is added to the DOM
|
||||
connectedCallback() {
|
||||
// Initialize the map when the element is added to the DOM
|
||||
setTimeout(() => this._initializeMap(), 0);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._map) {
|
||||
this._map.remove();
|
||||
}
|
||||
}
|
||||
|
||||
_initializeMap() {
|
||||
const centroid = JSON.parse(this.getAttribute("centroid"));
|
||||
const organization_id = this.getAttribute("organization-id");
|
||||
const tegola = this.getAttribute("tegola");
|
||||
const xmin = parseFloat(this.getAttribute("xmin"));
|
||||
const ymin = parseFloat(this.getAttribute("ymin"));
|
||||
const xmax = parseFloat(this.getAttribute("xmax"));
|
||||
const ymax = parseFloat(this.getAttribute("ymax"));
|
||||
const bounds = [
|
||||
[xmin, ymin],
|
||||
[xmax, ymax],
|
||||
];
|
||||
|
||||
const mapElement = this.shadowRoot.querySelector("#map");
|
||||
|
||||
this._map = new maplibregl.Map({
|
||||
center: centroid.coordinates,
|
||||
container: mapElement,
|
||||
style: "https://tiles.stadiamaps.com/styles/alidade_smooth.json", // Style URL; see our documentation for more options
|
||||
}).fitBounds(bounds, {
|
||||
padding: { top: 10, bottom: 10, left: 10, right: 10 },
|
||||
});
|
||||
this._map.on("load", () => {
|
||||
this.dispatchEvent(new CustomEvent("load"), {
|
||||
bubbles: true,
|
||||
composed: true, // Allows event to cross shadow DOM boundary
|
||||
detail: {
|
||||
map: this,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initial render of component
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
@import url('//unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css');
|
||||
.mapboxgl-ctrl-bottom-right {
|
||||
display: none;
|
||||
}
|
||||
.map-container {
|
||||
background-color: #e9ecef;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
||||
height: 500px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
#map {
|
||||
height: 500px;
|
||||
width:100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
#map img {
|
||||
max-width: none;
|
||||
min-width: 0px;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="map-container" class="map-container">
|
||||
<div id="map"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
addLayer(a) {
|
||||
return this._map.addLayer(a);
|
||||
}
|
||||
addSource(a, b) {
|
||||
return this._map.addSource(a, b);
|
||||
}
|
||||
jumpTo(args) {
|
||||
return this._map.jumpTo(args);
|
||||
}
|
||||
on(a, b) {
|
||||
return this._map.on(a, b);
|
||||
}
|
||||
once(a, b) {
|
||||
return this._map.once(a, b);
|
||||
}
|
||||
queryRenderedFeatures(a) {
|
||||
return this._map.queryRenderedFeatures(a);
|
||||
}
|
||||
|
||||
setMarker(coords) {
|
||||
console.log("Setting map marker", coords);
|
||||
this._map.jumpTo({
|
||||
center: coords,
|
||||
zoom: 14,
|
||||
});
|
||||
this._markers.forEach((marker) => marker.remove());
|
||||
|
||||
const marker = new mapboxgl.Marker({
|
||||
color: "#FF0000",
|
||||
draggable: true,
|
||||
})
|
||||
.setLngLat(coords)
|
||||
.addTo(map);
|
||||
marker.on("dragend", function (e) {
|
||||
const markerDraggedEvent = new CustomEvent("markerdragend", {
|
||||
detail: {
|
||||
marker: marker,
|
||||
},
|
||||
});
|
||||
mapContainer.dispatchEvent(markerDraggedEvent);
|
||||
});
|
||||
this._markers = [marker];
|
||||
}
|
||||
|
||||
SetLayoutProperty(layout, property, value) {
|
||||
return this._map.setLayoutProperty(layout, property, value);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("map-admin", MapAdmin);
|
||||
168
static/js/map-aggregate.js
Normal file
168
static/js/map-aggregate.js
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
// A map that can be used to locate a single point by setting its location explicitly
|
||||
// or by allowing the user to move a marker.
|
||||
class MapAggregate extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Create a shadow DOM
|
||||
this.attachShadow({ mode: "open" });
|
||||
|
||||
// Initial render
|
||||
this.render();
|
||||
}
|
||||
|
||||
// Lifecycle: when element is added to the DOM
|
||||
connectedCallback() {
|
||||
// Initialize the map when the element is added to the DOM
|
||||
setTimeout(() => this._initializeMap(), 0);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._map) {
|
||||
this._map.remove();
|
||||
}
|
||||
}
|
||||
|
||||
_initializeMap() {
|
||||
const centroid = JSON.parse(this.getAttribute("centroid"));
|
||||
const organization_id = Number(this.getAttribute("organization-id") || 0);
|
||||
const tegola = this.getAttribute("tegola");
|
||||
const xmin = parseFloat(this.getAttribute("xmin"));
|
||||
const ymin = parseFloat(this.getAttribute("ymin"));
|
||||
const xmax = parseFloat(this.getAttribute("xmax"));
|
||||
const ymax = parseFloat(this.getAttribute("ymax"));
|
||||
const bounds = [
|
||||
[xmin, ymin],
|
||||
[xmax, ymax],
|
||||
];
|
||||
|
||||
const mapElement = this.shadowRoot.querySelector("#map");
|
||||
this._map = new maplibregl.Map({
|
||||
bounds: bounds,
|
||||
container: mapElement,
|
||||
style: "https://tiles.stadiamaps.com/styles/alidade_smooth.json",
|
||||
});
|
||||
console.log("Initializing map to bounds", bounds);
|
||||
this._map.on("load", () => {
|
||||
this._map.addSource("tegola", {
|
||||
type: "vector",
|
||||
tiles: [
|
||||
`${tegola}maps/nidus/{z}/{x}/{y}?id=${organization_id}&organization_id=${organization_id}`,
|
||||
],
|
||||
});
|
||||
this._map.addLayer({
|
||||
id: "mosquito_source",
|
||||
type: "fill",
|
||||
filter: [
|
||||
"==",
|
||||
["zoom"],
|
||||
["+", 2, ["to-number", ["get", "resolution"]]],
|
||||
],
|
||||
source: "tegola",
|
||||
"source-layer": "mosquito_source",
|
||||
paint: {
|
||||
"fill-opacity": 0.4,
|
||||
"fill-color": "#dc3545",
|
||||
},
|
||||
});
|
||||
this._map.addLayer({
|
||||
id: "service_request",
|
||||
type: "fill",
|
||||
filter: [
|
||||
"==",
|
||||
["zoom"],
|
||||
["+", 2, ["to-number", ["get", "resolution"]]],
|
||||
],
|
||||
source: "tegola",
|
||||
"source-layer": "service_request",
|
||||
paint: {
|
||||
"fill-opacity": 0.4,
|
||||
"fill-color": "#ffc107",
|
||||
},
|
||||
});
|
||||
this._map.addLayer({
|
||||
id: "trap",
|
||||
type: "fill",
|
||||
filter: [
|
||||
"==",
|
||||
["zoom"],
|
||||
["+", 2, ["to-number", ["get", "resolution"]]],
|
||||
],
|
||||
source: "tegola",
|
||||
"source-layer": "trap",
|
||||
paint: {
|
||||
"fill-opacity": 0.4,
|
||||
"fill-color": "#0dcaf0",
|
||||
},
|
||||
});
|
||||
this._map.addLayer({
|
||||
id: "service-area",
|
||||
source: "tegola",
|
||||
"source-layer": "service-area-bounds",
|
||||
type: "line",
|
||||
paint: {
|
||||
"line-color": "#f00",
|
||||
},
|
||||
});
|
||||
this._map.on("mouseenter", "mosquito_source", (e) => {
|
||||
this._map.getCanvas().style.cursor = "pointer";
|
||||
});
|
||||
this._map.on("mouseleave", "mosquito_source", (e) => {
|
||||
this._map.getCanvas().style.cursor = "";
|
||||
});
|
||||
const _handleClick = (e) => {
|
||||
const feature = e.features[0];
|
||||
const coordinates = feature.geometry.coordinates.slice();
|
||||
const properties = feature.properties;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("cell-click", {
|
||||
bubbles: true,
|
||||
composed: true, // Allows event to cross shadow DOM boundary
|
||||
detail: {
|
||||
cell: properties.cell,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
this._map.on("click", "mosquito_source", _handleClick);
|
||||
this._map.on("click", "service_request", _handleClick);
|
||||
this._map.on("click", "trap", _handleClick);
|
||||
});
|
||||
}
|
||||
|
||||
// Initial render of component
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
@import url("//unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css");
|
||||
.map-container {
|
||||
background-color: #e9ecef;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
||||
height: 500px;
|
||||
margin-top: 20px;
|
||||
position: relative;
|
||||
}
|
||||
#map {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: 100%
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="map-container" class="map-container">
|
||||
<div id="map"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
jumpTo(args) {
|
||||
this._map.jumpTo(args);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("map-aggregate", MapAggregate);
|
||||
175
static/js/map-arcgis-tile.js
Normal file
175
static/js/map-arcgis-tile.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// A map that can show ArcGIS map tiles
|
||||
class MapArcgisTile extends HTMLElement {
|
||||
static observedAttributes = ["latitude", "longitude"];
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Create a shadow DOM
|
||||
this.attachShadow({ mode: "open" });
|
||||
|
||||
// Initial render
|
||||
this.render();
|
||||
|
||||
this._map = null;
|
||||
this._markers = [];
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, old_value, new_value) {
|
||||
//console.log("map-arcgis-tile: attribute changed", name, old_value, new_value);
|
||||
if ((name == "latitude" || name == "longitude") && this._map != null) {
|
||||
const latitude = parseFloat(this.getAttribute("latitude"));
|
||||
const longitude = parseFloat(this.getAttribute("longitude"));
|
||||
this._map.jumpTo({
|
||||
center: [longitude, latitude],
|
||||
zoom: 19,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle: when element is added to the DOM
|
||||
connectedCallback() {
|
||||
// Initialize the map when the element is added to the DOM
|
||||
setTimeout(() => this._initializeMap(), 0);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._map) {
|
||||
this._map.remove();
|
||||
}
|
||||
}
|
||||
|
||||
_initializeMap() {
|
||||
const arcgis_access_token = this.getAttribute("arcgis-access-token");
|
||||
const latitude = parseFloat(this.getAttribute("latitude"));
|
||||
const longitude = parseFloat(this.getAttribute("longitude"));
|
||||
const organization_id = Number(this.getAttribute("organization-id") || 0);
|
||||
const tegola = this.getAttribute("tegola");
|
||||
|
||||
const mapElement = this.shadowRoot.querySelector("#map");
|
||||
this._map = new maplibregl.Map({
|
||||
center: [longitude, latitude],
|
||||
container: mapElement,
|
||||
style: "https://tiles.stadiamaps.com/styles/osm_bright.json",
|
||||
zoom: 20,
|
||||
});
|
||||
console.log("ArcGIS token", arcgis_access_token);
|
||||
const basemap_style = maplibreArcGIS.BasemapStyle.applyStyle(this._map, {
|
||||
style: "arcgis/light-gray",
|
||||
token: arcgis_access_token,
|
||||
});
|
||||
this._map.on("load", () => {
|
||||
console.log("map-arcgis-tile loaded");
|
||||
if (organization_id != 0) {
|
||||
this._map.addSource("tegola", {
|
||||
type: "vector",
|
||||
tiles: [
|
||||
`${tegola}maps/nidus/{z}/{x}/{y}?id=${organization_id}&organization_id=${organization_id}`,
|
||||
],
|
||||
});
|
||||
this._map.addLayer({
|
||||
id: "service-area",
|
||||
source: "tegola",
|
||||
"source-layer": "service-area-bounds",
|
||||
type: "line",
|
||||
paint: {
|
||||
"line-color": "#f00",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (arcgis_access_token != "") {
|
||||
this._map.addSource("flyover", {
|
||||
type: "raster",
|
||||
tiles: [
|
||||
"https://tiles.arcgis.com/tiles/pV7SH1EgRc6tpxlJ/arcgis/rest/services/TrimmedFlyover2025/MapServer/tile/{z}/{y}/{x}?token=" +
|
||||
arcgis_access_token,
|
||||
],
|
||||
});
|
||||
console.log("added arcgis tile source");
|
||||
this._map.addLayer({
|
||||
id: "flyover-layer",
|
||||
source: "flyover",
|
||||
type: "raster",
|
||||
});
|
||||
console.log("added arcgis tile layer");
|
||||
}
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("load", {
|
||||
bubbles: true,
|
||||
composed: true, // Allows event to cross shadow DOM boundary
|
||||
detail: {
|
||||
map: this,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
this._map.on("click", (e) => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("map-click", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: {
|
||||
lng: e.lngLat.lng,
|
||||
lat: e.lngLat.lat,
|
||||
map: this,
|
||||
point: e.point,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Initial render of component
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
@import url("//unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css");
|
||||
#map {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="map"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
addLayer(a) {
|
||||
return this._map.addLayer(a);
|
||||
}
|
||||
addSource(a, b) {
|
||||
return this._map.addSource(a, b);
|
||||
}
|
||||
jumpTo(args) {
|
||||
return this._map.jumpTo(args);
|
||||
}
|
||||
on(a, b) {
|
||||
return this._map.on(a, b);
|
||||
}
|
||||
once(a, b) {
|
||||
return this._map.once(a, b);
|
||||
}
|
||||
queryRenderedFeatures(a) {
|
||||
return this._map.queryRenderedFeatures(a);
|
||||
}
|
||||
|
||||
FitBounds(bounds, options) {
|
||||
return this._map.fitBounds(bounds, options);
|
||||
}
|
||||
SetLayoutProperty(layout, property, value) {
|
||||
return this._map.setLayoutProperty(layout, property, value);
|
||||
}
|
||||
SetMarkers(markers) {
|
||||
console.log("Setting map markers", markers);
|
||||
this._markers.forEach((marker) => marker.remove());
|
||||
this._markers = markers.map((m) => {
|
||||
return new maplibregl.Marker({
|
||||
color: "#FF0000",
|
||||
draggable: false,
|
||||
})
|
||||
.setLngLat([m.longitude, m.latitude])
|
||||
.addTo(this._map);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("map-arcgis-tile", MapArcgisTile);
|
||||
166
static/js/map-cell.js
Normal file
166
static/js/map-cell.js
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// A map for showing a single h3 cell
|
||||
class MapCell extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Create a shadow DOM
|
||||
this.attachShadow({ mode: "open" });
|
||||
|
||||
this._markers = [];
|
||||
// Initial render
|
||||
this.render();
|
||||
}
|
||||
|
||||
// Lifecycle: when element is added to the DOM
|
||||
connectedCallback() {
|
||||
// Initialize the map when the element is added to the DOM
|
||||
setTimeout(() => this._initializeMap(), 0);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._map) {
|
||||
this._map.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle: watch these attributes for changes
|
||||
static get observedAttributes() {
|
||||
return [
|
||||
"api-key",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"organization-id",
|
||||
"tegola",
|
||||
"zoom",
|
||||
];
|
||||
}
|
||||
|
||||
// Lifecycle: respond to attribute changes
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
// Only handle if map exists and values actually changed
|
||||
if (!this._map || oldValue === newValue) return;
|
||||
|
||||
if (name === "api-key") {
|
||||
this._apiKey = newValue;
|
||||
}
|
||||
|
||||
if (name === "latitude" || name === "longitude") {
|
||||
if (this.hasAttribute("latitude") && this.hasAttribute("longitude")) {
|
||||
const lat = Number(this.getAttribute("latitude"));
|
||||
const lng = Number(this.getAttribute("longitude"));
|
||||
this._map.setCenter([lat, lng]);
|
||||
}
|
||||
}
|
||||
|
||||
if (name === "organization-id") {
|
||||
this._organizationID = newValue;
|
||||
}
|
||||
|
||||
if (name === "tegola") {
|
||||
this._tegola = newValue;
|
||||
}
|
||||
|
||||
if (name === "zoom") {
|
||||
this._map.setZoom(Number(newValue));
|
||||
}
|
||||
}
|
||||
|
||||
_initializeMap() {
|
||||
const geojson = JSON.parse(this.getAttribute("geojson"));
|
||||
const lat = Number(this.getAttribute("latitude") || 36.2);
|
||||
const lng = Number(this.getAttribute("longitude") || -119.2);
|
||||
const organization_id = Number(this.getAttribute("organization-id") || 0);
|
||||
const tegola = this.getAttribute("tegola");
|
||||
const zoom = Number(this.getAttribute("zoom") || 15);
|
||||
|
||||
const mapElement = this.shadowRoot.querySelector("#map");
|
||||
this._map = new maplibregl.Map({
|
||||
container: mapElement,
|
||||
center: {
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
},
|
||||
style: "https://tiles.stadiamaps.com/styles/alidade_smooth.json",
|
||||
zoom: zoom,
|
||||
});
|
||||
const layer_id = "geojson-layer";
|
||||
const source_id = "geojson-source";
|
||||
this._map.on("load", () => {
|
||||
this._map.addSource(source_id, {
|
||||
data: geojson,
|
||||
type: "geojson",
|
||||
});
|
||||
this._map.addLayer({
|
||||
id: layer_id,
|
||||
interactive: false,
|
||||
paint: {
|
||||
"fill-opacity": 0.3,
|
||||
"fill-color": "#dc3545",
|
||||
},
|
||||
source: source_id,
|
||||
type: "fill",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initial render of component
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
@import url("//unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css");
|
||||
.map-container {
|
||||
background-color: #e9ecef;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
||||
height: 500px;
|
||||
margin-top: 20px;
|
||||
position: relative;
|
||||
}
|
||||
#map {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: 100%
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="map-container" class="map-container">
|
||||
<div id="map"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
jumpTo(args) {
|
||||
this._map.jumpTo(args);
|
||||
}
|
||||
|
||||
setMarker(coords) {
|
||||
console.log("Setting map marker", coords);
|
||||
this._map.jumpTo({
|
||||
center: coords,
|
||||
zoom: 14,
|
||||
});
|
||||
this._markers.forEach((marker) => marker.remove());
|
||||
|
||||
const marker = new maplibregl.Marker({
|
||||
color: "#FF0000",
|
||||
draggable: true,
|
||||
})
|
||||
.setLngLat(coords)
|
||||
.addTo(this._map);
|
||||
marker.on("dragend", function (e) {
|
||||
const markerDraggedEvent = new CustomEvent("markerdragend", {
|
||||
detail: {
|
||||
marker: marker,
|
||||
},
|
||||
});
|
||||
mapContainer.dispatchEvent(markerDraggedEvent);
|
||||
});
|
||||
this._markers = [marker];
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("map-cell", MapCell);
|
||||
125
static/js/map-locator-ro.js
Normal file
125
static/js/map-locator-ro.js
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// A map that can be used to locate a single point by setting its location explicitly
|
||||
// or by allowing the user to move a marker.
|
||||
class MapLocatorReadOnly extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Create a shadow DOM
|
||||
this.attachShadow({ mode: "open" });
|
||||
|
||||
// Initial render
|
||||
this.render();
|
||||
|
||||
// markers shown on the map. Should be none or 1, generally.
|
||||
this._markers = [];
|
||||
}
|
||||
|
||||
// Lifecycle: when element is added to the DOM
|
||||
connectedCallback() {
|
||||
// Initialize the map when the element is added to the DOM
|
||||
setTimeout(() => this._initializeMap(), 0);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._map) {
|
||||
this._map.remove();
|
||||
}
|
||||
}
|
||||
|
||||
_initializeMap() {
|
||||
console.log("Setting up the locator read-only...");
|
||||
const marker_str = this.getAttribute("marker");
|
||||
const marker = JSON.parse(marker_str);
|
||||
|
||||
const mapElement = this.shadowRoot.querySelector("#map");
|
||||
this._map = new maplibregl.Map({
|
||||
container: mapElement,
|
||||
center: marker.coordinates,
|
||||
//style: "https://tiles.stadiamaps.com/styles/alidade_smooth.json",
|
||||
style: "https://tiles.stadiamaps.com/styles/osm_bright.json",
|
||||
zoom: 16,
|
||||
});
|
||||
this._map.on("load", () => {
|
||||
console.log("map locator read-only loaded");
|
||||
const m = new maplibregl.Marker({
|
||||
color: "#FF0000",
|
||||
draggable: true,
|
||||
})
|
||||
.setLngLat(marker.coordinates)
|
||||
.addTo(this._map);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("load", {
|
||||
bubbles: true,
|
||||
composed: true, // Allows event to cross shadow DOM boundary
|
||||
detail: {
|
||||
map: this,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
this._map.on("zoomend", (e) => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("zoomend", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: e,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Initial render of component
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
@import url("//unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css");
|
||||
#map {
|
||||
height: 100%;
|
||||
width:100%;
|
||||
}
|
||||
#map img {
|
||||
max-width: none;
|
||||
min-width: 0px;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="map"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
GetZoom() {
|
||||
return this._map.getZoom();
|
||||
}
|
||||
|
||||
JumpTo(args) {
|
||||
this._map.jumpTo(args);
|
||||
}
|
||||
|
||||
PanTo(coords, options) {
|
||||
this._map.panTo(coords, options);
|
||||
}
|
||||
|
||||
SetMarker(coords) {
|
||||
console.log("Setting map marker", coords);
|
||||
this._markers.forEach((marker) => marker.remove());
|
||||
|
||||
const marker = new maplibregl.Marker({
|
||||
color: "#FF0000",
|
||||
draggable: true,
|
||||
})
|
||||
.setLngLat(coords)
|
||||
.addTo(this._map);
|
||||
marker.on("dragend", (e) => {
|
||||
const markerDraggedEvent = new CustomEvent("markerdragend", {
|
||||
detail: {
|
||||
marker: marker,
|
||||
},
|
||||
});
|
||||
this.dispatchEvent(markerDraggedEvent);
|
||||
});
|
||||
this._markers = [marker];
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("map-locator-ro", MapLocatorReadOnly);
|
||||
146
static/js/map-locator.js
Normal file
146
static/js/map-locator.js
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// A map that can be used to locate a single point by setting its location explicitly
|
||||
// or by allowing the user to move a marker.
|
||||
class MapLocator extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Create a shadow DOM
|
||||
this.attachShadow({ mode: "open" });
|
||||
|
||||
// Initial render
|
||||
this.render();
|
||||
|
||||
// markers shown on the map. Should be none or 1, generally.
|
||||
this._markers = [];
|
||||
}
|
||||
|
||||
// Lifecycle: when element is added to the DOM
|
||||
connectedCallback() {
|
||||
// Initialize the map when the element is added to the DOM
|
||||
setTimeout(() => this._initializeMap(), 0);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._map) {
|
||||
this._map.remove();
|
||||
}
|
||||
}
|
||||
|
||||
_initializeMap() {
|
||||
console.log("Setting up the map...");
|
||||
const apiKey = this.getAttribute("api-key");
|
||||
const lat = Number(this.getAttribute("latitude") || 36.2);
|
||||
const lng = Number(this.getAttribute("longitude") || -119.2);
|
||||
const zoom = Number(this.getAttribute("zoom") || 15);
|
||||
|
||||
const mapElement = this.shadowRoot.querySelector("#map");
|
||||
this._map = new maplibregl.Map({
|
||||
container: mapElement,
|
||||
center: {
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
},
|
||||
style: "https://tiles.stadiamaps.com/styles/alidade_smooth.json",
|
||||
zoom: zoom,
|
||||
});
|
||||
/*
|
||||
map.addControl(new maplibregl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true
|
||||
},
|
||||
trackUserLocation: true,
|
||||
showUserHeading: true
|
||||
}));
|
||||
map.addControl(new maplibregl.NavigationControl());
|
||||
*/
|
||||
this._map.on("click", (e) => {
|
||||
e.preventDefault();
|
||||
console.log("internal click", e);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("click", {
|
||||
bubbles: true,
|
||||
composed: true, // Allows event to cross shadow DOM boundary
|
||||
detail: {
|
||||
lngLat: e.lngLat,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
this._map.on("load", () => {
|
||||
console.log("map loaded");
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("load", {
|
||||
bubbles: true,
|
||||
composed: true, // Allows event to cross shadow DOM boundary
|
||||
detail: {
|
||||
map: this,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
this._map.on("zoomend", (e) => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("zoomend", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: e,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Initial render of component
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
@import url("//unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css");
|
||||
#map {
|
||||
height: 100%;
|
||||
width:100%;
|
||||
}
|
||||
#map img {
|
||||
max-width: none;
|
||||
min-width: 0px;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="map"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
GetZoom() {
|
||||
return this._map.getZoom();
|
||||
}
|
||||
|
||||
JumpTo(args) {
|
||||
this._map.jumpTo(args);
|
||||
}
|
||||
|
||||
PanTo(coords, options) {
|
||||
this._map.panTo(coords, options);
|
||||
}
|
||||
|
||||
SetMarker(coords) {
|
||||
console.log("Setting map marker", coords);
|
||||
this._markers.forEach((marker) => marker.remove());
|
||||
|
||||
const marker = new maplibregl.Marker({
|
||||
color: "#FF0000",
|
||||
draggable: true,
|
||||
})
|
||||
.setLngLat(coords)
|
||||
.addTo(this._map);
|
||||
marker.on("dragend", (e) => {
|
||||
const markerDraggedEvent = new CustomEvent("markerdragend", {
|
||||
detail: {
|
||||
marker: marker,
|
||||
},
|
||||
});
|
||||
this.dispatchEvent(markerDraggedEvent);
|
||||
});
|
||||
this._markers = [marker];
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("map-locator", MapLocator);
|
||||
166
static/js/map-multipoint.js
Normal file
166
static/js/map-multipoint.js
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
var map = null;
|
||||
// A map that shows multiple single point locations.
|
||||
// Points have additional detail popups.
|
||||
class MapMultipoint extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Create a shadow DOM
|
||||
this.attachShadow({ mode: "open" });
|
||||
|
||||
// Initial render
|
||||
this.render();
|
||||
|
||||
// Keep track of any 'on' calls to add to the map as soon as we create it.
|
||||
this._preOns = [];
|
||||
this._map = null;
|
||||
this._markers = [];
|
||||
}
|
||||
|
||||
// Lifecycle: when element is added to the DOM
|
||||
connectedCallback() {
|
||||
// Initialize the map when the element is added to the DOM
|
||||
setTimeout(() => this._initializeMap(), 0);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._map) {
|
||||
this._map.remove();
|
||||
}
|
||||
}
|
||||
_bounds() {
|
||||
const xmin = parseFloat(this.getAttribute("xmin"));
|
||||
const ymin = parseFloat(this.getAttribute("ymin"));
|
||||
const xmax = parseFloat(this.getAttribute("xmax"));
|
||||
const ymax = parseFloat(this.getAttribute("ymax"));
|
||||
let bounds = [
|
||||
[xmin, ymin],
|
||||
[xmax, ymax],
|
||||
];
|
||||
if (xmin == 0 || xmax == 0 || ymin == 0 || ymax == 0) {
|
||||
bounds = [
|
||||
[-125, 25],
|
||||
[-70, 50],
|
||||
];
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
_initializeMap() {
|
||||
const bounds = this._bounds();
|
||||
const organization_id = Number(this.getAttribute("organization-id") || 0);
|
||||
const tegola = this.getAttribute("tegola");
|
||||
|
||||
const mapElement = this.shadowRoot.querySelector("#map");
|
||||
this._map = new maplibregl.Map({
|
||||
bounds: bounds,
|
||||
container: mapElement,
|
||||
style: "https://tiles.stadiamaps.com/styles/osm_bright.json",
|
||||
});
|
||||
this._map.on("load", () => {
|
||||
if (organization_id != 0) {
|
||||
this._map.addSource("tegola", {
|
||||
type: "vector",
|
||||
tiles: [
|
||||
`${tegola}maps/nidus/{z}/{x}/{y}?id=${organization_id}&organization_id=${organization_id}`,
|
||||
],
|
||||
});
|
||||
this._map.addLayer({
|
||||
id: "service-area",
|
||||
source: "tegola",
|
||||
"source-layer": "service-area-bounds",
|
||||
type: "line",
|
||||
paint: {
|
||||
"line-color": "#f00",
|
||||
},
|
||||
});
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent("load"), {
|
||||
bubbles: true,
|
||||
composed: true, // Allows event to cross shadow DOM boundary
|
||||
detail: {
|
||||
map: this,
|
||||
},
|
||||
});
|
||||
});
|
||||
for (const on of this._preOns) {
|
||||
this._map.on(...on);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial render of component
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
@import url("//unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css");
|
||||
#map {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="map"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
addLayer(a) {
|
||||
return this._map.addLayer(a);
|
||||
}
|
||||
addSource(a, b) {
|
||||
return this._map.addSource(a, b);
|
||||
}
|
||||
flyTo(a, b) {
|
||||
return this._map.flyTo(a, b);
|
||||
}
|
||||
getCanvas(...args) {
|
||||
return this._map.getCanvas(...args);
|
||||
}
|
||||
getContainer(...args) {
|
||||
return this._map.getContainer(...args);
|
||||
}
|
||||
jumpTo(args) {
|
||||
return this._map.jumpTo(args);
|
||||
}
|
||||
on(...args) {
|
||||
if (this._map != null) {
|
||||
return this._map.on(...args);
|
||||
} else {
|
||||
this._preOns.push(args);
|
||||
}
|
||||
}
|
||||
once(a, b) {
|
||||
return this._map.once(a, b);
|
||||
}
|
||||
panTo(a, b) {
|
||||
return this._map.panTo(a, b);
|
||||
}
|
||||
queryRenderedFeatures(a) {
|
||||
return this._map.queryRenderedFeatures(a);
|
||||
}
|
||||
|
||||
ClearMarkers() {
|
||||
this._markers.forEach((marker) => marker.remove());
|
||||
}
|
||||
FitBounds(bounds, options) {
|
||||
return this._map.fitBounds(bounds, options);
|
||||
}
|
||||
// Reset the view back to whatever the html properties define
|
||||
ResetCamera() {
|
||||
const bounds = this._bounds();
|
||||
this.FitBounds(bounds, {
|
||||
linear: false,
|
||||
});
|
||||
}
|
||||
SetLayoutProperty(layout, property, value) {
|
||||
return this._map.setLayoutProperty(layout, property, value);
|
||||
}
|
||||
SetMarkers(markers) {
|
||||
console.log("Setting map markers", markers);
|
||||
this._markers.forEach((marker) => marker.remove());
|
||||
this._markers = markers;
|
||||
for (let m of markers) {
|
||||
m.addTo(this._map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("map-multipoint", MapMultipoint);
|
||||
167
static/js/map-proxied-arcgis-tile.js
Normal file
167
static/js/map-proxied-arcgis-tile.js
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
// A map that shows multiple single point locations.
|
||||
// Points have additional detail popups.
|
||||
// The background layer is proxied from Arcgis
|
||||
class MapProxiedArcgisTile extends HTMLElement {
|
||||
static observedAttributes = ["latitude", "longitude"];
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Create a shadow DOM
|
||||
this.attachShadow({ mode: "open" });
|
||||
|
||||
// Initial render
|
||||
this.render();
|
||||
|
||||
// Keep track of any 'on' calls to add to the map as soon as we create it.
|
||||
this._preOns = [];
|
||||
this._map = null;
|
||||
this._markers = [];
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, old_value, new_value) {
|
||||
//console.log("map-arcgis-tile: attribute changed", name, old_value, new_value);
|
||||
if ((name == "latitude" || name == "longitude") && this._map != null) {
|
||||
const latitude = parseFloat(this.getAttribute("latitude"));
|
||||
const longitude = parseFloat(this.getAttribute("longitude"));
|
||||
this._map.jumpTo({
|
||||
center: [longitude, latitude],
|
||||
zoom: 19,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle: when element is added to the DOM
|
||||
connectedCallback() {
|
||||
// Initialize the map when the element is added to the DOM
|
||||
setTimeout(() => this._initializeMap(), 0);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._map) {
|
||||
this._map.remove();
|
||||
}
|
||||
}
|
||||
|
||||
_initializeMap() {
|
||||
const latitude = parseFloat(this.getAttribute("latitude"));
|
||||
const longitude = parseFloat(this.getAttribute("longitude"));
|
||||
const organization_id = Number(this.getAttribute("organization-id") || 0);
|
||||
const tegola = this.getAttribute("tegola");
|
||||
const url_tiles = this.getAttribute("url-tiles");
|
||||
|
||||
const mapElement = this.shadowRoot.querySelector("#map");
|
||||
this._map = new maplibregl.Map({
|
||||
center: [longitude, latitude],
|
||||
container: mapElement,
|
||||
style: "https://tiles.stadiamaps.com/styles/osm_bright.json",
|
||||
zoom: 19,
|
||||
});
|
||||
this._map.on("load", () => {
|
||||
if (organization_id != 0) {
|
||||
this._map.addSource("tegola", {
|
||||
type: "vector",
|
||||
tiles: [
|
||||
`${tegola}maps/nidus/{z}/{x}/{y}?id=${organization_id}&organization_id=${organization_id}`,
|
||||
],
|
||||
});
|
||||
this._map.addLayer({
|
||||
id: "service-area",
|
||||
source: "tegola",
|
||||
"source-layer": "service-area-bounds",
|
||||
type: "line",
|
||||
paint: {
|
||||
"line-color": "#f00",
|
||||
},
|
||||
});
|
||||
}
|
||||
this._map.addSource("flyover", {
|
||||
type: "raster",
|
||||
tiles: [url_tiles],
|
||||
});
|
||||
this._map.addLayer({
|
||||
id: "flyover-layer",
|
||||
source: "flyover",
|
||||
type: "raster",
|
||||
});
|
||||
this.dispatchEvent(new CustomEvent("load"), {
|
||||
bubbles: true,
|
||||
composed: true, // Allows event to cross shadow DOM boundary
|
||||
detail: {
|
||||
map: this,
|
||||
},
|
||||
});
|
||||
this._map.on("click", (e) => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("map-click", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: {
|
||||
lng: e.lngLat.lng,
|
||||
lat: e.lngLat.lat,
|
||||
map: this,
|
||||
point: e.point,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
for (const on of this._preOns) {
|
||||
this._map.on(...on);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial render of component
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
@import url("//unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css");
|
||||
#map {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="map"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
addLayer(a) {
|
||||
return this._map.addLayer(a);
|
||||
}
|
||||
addSource(a, b) {
|
||||
return this._map.addSource(a, b);
|
||||
}
|
||||
jumpTo(args) {
|
||||
return this._map.jumpTo(args);
|
||||
}
|
||||
on(...args) {
|
||||
if (this._map != null) {
|
||||
return this._map.on(...args);
|
||||
} else {
|
||||
this._preOns.push(args);
|
||||
}
|
||||
}
|
||||
once(a, b) {
|
||||
return this._map.once(a, b);
|
||||
}
|
||||
queryRenderedFeatures(a) {
|
||||
return this._map.queryRenderedFeatures(a);
|
||||
}
|
||||
|
||||
FitBounds(bounds, options) {
|
||||
return this._map.fitBounds(bounds, options);
|
||||
}
|
||||
SetLayoutProperty(layout, property, value) {
|
||||
return this._map.setLayoutProperty(layout, property, value);
|
||||
}
|
||||
SetMarkers(markers) {
|
||||
console.log("Setting map markers", markers);
|
||||
this._markers.forEach((marker) => marker.remove());
|
||||
this._markers = markers;
|
||||
for (let m of markers) {
|
||||
m.addTo(this._map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("map-proxied-arcgis-tile", MapProxiedArcgisTile);
|
||||
408
static/js/map-routing.js
Normal file
408
static/js/map-routing.js
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
// A test of maplibre-gl in a custom element
|
||||
class MapRouting extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Create a shadow DOM
|
||||
this.attachShadow({ mode: "open" });
|
||||
|
||||
// Initial render
|
||||
this.render();
|
||||
|
||||
this._map = null;
|
||||
|
||||
// markers shown on the map
|
||||
this._markers = [];
|
||||
}
|
||||
|
||||
// Lifecycle: when element is added to the DOM
|
||||
connectedCallback() {
|
||||
// Initialize the map when the element is added to the DOM
|
||||
setTimeout(() => this._initializeMap(), 0);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._map) {
|
||||
this._map.remove();
|
||||
}
|
||||
}
|
||||
|
||||
_initializeMap() {
|
||||
const centroid = JSON.parse(this.getAttribute("centroid"));
|
||||
const organization_id = this.getAttribute("organization-id");
|
||||
const tegola = this.getAttribute("tegola");
|
||||
const xmin = parseFloat(this.getAttribute("xmin"));
|
||||
const ymin = parseFloat(this.getAttribute("ymin"));
|
||||
const xmax = parseFloat(this.getAttribute("xmax"));
|
||||
const ymax = parseFloat(this.getAttribute("ymax"));
|
||||
const bounds = [
|
||||
[xmin, ymin],
|
||||
[xmax, ymax],
|
||||
];
|
||||
|
||||
const mapElement = this.shadowRoot.querySelector("#map");
|
||||
|
||||
/*
|
||||
this._map = new maplibregl.Map({
|
||||
center: centroid.coordinates,
|
||||
container: mapElement,
|
||||
style: "https://tiles.stadiamaps.com/styles/alidade_smooth.json", // Style URL; see our documentation for more options
|
||||
}).fitBounds(bounds, {
|
||||
padding: { top: 10, bottom: 10, left: 10, right: 10 },
|
||||
});
|
||||
this._map.on("load", () => {
|
||||
this.dispatchEvent(new CustomEvent("load"), {
|
||||
bubbles: true,
|
||||
composed: true, // Allows event to cross shadow DOM boundary
|
||||
detail: {
|
||||
map: this,
|
||||
},
|
||||
});
|
||||
});
|
||||
*/
|
||||
this._map = new maplibregl.Map({
|
||||
center: {
|
||||
lat: 36.351947895503585,
|
||||
lng: -119.31857880996313,
|
||||
},
|
||||
container: mapElement,
|
||||
style: "https://tiles.stadiamaps.com/styles/alidade_smooth.json", // Style URL; see our documentation for more options
|
||||
}).fitBounds(
|
||||
[
|
||||
{ lat: 36.33870557056423, lng: -119.35466592321588 },
|
||||
{ lat: 36.36630172845781, lng: -119.28771302024407 },
|
||||
],
|
||||
{
|
||||
padding: { top: 10, bottom: 10, left: 10, right: 10 },
|
||||
},
|
||||
);
|
||||
const routeData = {
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[-119.31104, 36.3419],
|
||||
[-119.31005, 36.34185],
|
||||
[-119.30905, 36.34183],
|
||||
[-119.30815, 36.34181],
|
||||
[-119.30778, 36.34182],
|
||||
[-119.30755, 36.34184],
|
||||
[-119.30678, 36.34188],
|
||||
[-119.30656, 36.34188],
|
||||
[-119.30618, 36.34187],
|
||||
[-119.3056, 36.34187],
|
||||
[-119.3056, 36.34277],
|
||||
[-119.30561, 36.34345],
|
||||
[-119.3056, 36.34362],
|
||||
[-119.30562, 36.34523],
|
||||
[-119.30563, 36.34627],
|
||||
[-119.30563, 36.3473],
|
||||
[-119.30563, 36.3483],
|
||||
[-119.30566, 36.3501],
|
||||
[-119.30565, 36.35052],
|
||||
[-119.30566, 36.3508],
|
||||
[-119.30567, 36.35129],
|
||||
[-119.30567, 36.35191],
|
||||
[-119.30569, 36.35228],
|
||||
[-119.30573, 36.35276],
|
||||
[-119.30575, 36.35306],
|
||||
[-119.30574, 36.35338],
|
||||
[-119.30574, 36.35625],
|
||||
[-119.30574, 36.35641],
|
||||
[-119.30574, 36.35651],
|
||||
[-119.30572, 36.35806],
|
||||
[-119.30513, 36.35806],
|
||||
[-119.30353, 36.35805],
|
||||
[-119.30352, 36.35752],
|
||||
[-119.30393, 36.35753],
|
||||
[-119.30438, 36.35753],
|
||||
[-119.30438, 36.35753],
|
||||
[-119.3046, 36.35753],
|
||||
[-119.30512, 36.35753],
|
||||
[-119.3052, 36.35751],
|
||||
[-119.30524, 36.35746],
|
||||
[-119.30524, 36.35696],
|
||||
[-119.30521, 36.3569],
|
||||
[-119.30509, 36.35688],
|
||||
[-119.3046, 36.35688],
|
||||
[-119.30394, 36.35687],
|
||||
[-119.30308, 36.35687],
|
||||
[-119.3024, 36.35687],
|
||||
[-119.30181, 36.35687],
|
||||
[-119.30175, 36.35689],
|
||||
[-119.30173, 36.35695],
|
||||
[-119.30173, 36.35721],
|
||||
[-119.30133, 36.35721],
|
||||
[-119.30134, 36.3565],
|
||||
[-119.30191, 36.3565],
|
||||
[-119.30249, 36.3565],
|
||||
[-119.30345, 36.3565],
|
||||
[-119.30492, 36.35651],
|
||||
[-119.30509, 36.35651],
|
||||
[-119.30528, 36.35651],
|
||||
[-119.30574, 36.35651],
|
||||
[-119.30574, 36.35641],
|
||||
[-119.30574, 36.35625],
|
||||
[-119.30574, 36.35338],
|
||||
[-119.30575, 36.35306],
|
||||
[-119.30573, 36.35276],
|
||||
[-119.30569, 36.35228],
|
||||
[-119.30567, 36.35191],
|
||||
[-119.30567, 36.35129],
|
||||
[-119.30566, 36.3508],
|
||||
[-119.30565, 36.35052],
|
||||
[-119.30566, 36.3501],
|
||||
[-119.30597, 36.3501],
|
||||
[-119.30613, 36.35009],
|
||||
[-119.30629, 36.35008],
|
||||
[-119.30642, 36.35007],
|
||||
[-119.30688, 36.35001],
|
||||
[-119.30721, 36.34992],
|
||||
[-119.30754, 36.34984],
|
||||
[-119.30817, 36.34955],
|
||||
[-119.30851, 36.34946],
|
||||
[-119.30906, 36.34933],
|
||||
[-119.30917, 36.34932],
|
||||
[-119.30949, 36.34928],
|
||||
[-119.31007, 36.34928],
|
||||
[-119.31152, 36.34928],
|
||||
[-119.31195, 36.34928],
|
||||
[-119.3124, 36.34928],
|
||||
[-119.31337, 36.3493],
|
||||
[-119.31354, 36.3493],
|
||||
[-119.31374, 36.3493],
|
||||
[-119.31391, 36.3493],
|
||||
[-119.31417, 36.34932],
|
||||
[-119.31426, 36.34932],
|
||||
[-119.31456, 36.34933],
|
||||
[-119.31484, 36.34933],
|
||||
[-119.31505, 36.34933],
|
||||
[-119.31528, 36.34931],
|
||||
[-119.31654, 36.34921],
|
||||
[-119.31692, 36.3492],
|
||||
[-119.31708, 36.34921],
|
||||
[-119.31786, 36.34921],
|
||||
[-119.31867, 36.34918],
|
||||
[-119.31972, 36.34917],
|
||||
[-119.32087, 36.34918],
|
||||
[-119.32228, 36.34917],
|
||||
[-119.32246, 36.34917],
|
||||
[-119.32263, 36.34916],
|
||||
[-119.32313, 36.34915],
|
||||
[-119.32339, 36.34916],
|
||||
[-119.32375, 36.34918],
|
||||
[-119.324, 36.34917],
|
||||
[-119.3241, 36.34922],
|
||||
[-119.32555, 36.34923],
|
||||
[-119.32625, 36.34923],
|
||||
[-119.32706, 36.34922],
|
||||
[-119.32722, 36.34915],
|
||||
[-119.32777, 36.34917],
|
||||
[-119.32776, 36.34811],
|
||||
[-119.32776, 36.3475],
|
||||
[-119.32775, 36.34709],
|
||||
[-119.32772, 36.34709],
|
||||
[-119.32712, 36.34709],
|
||||
[-119.32713, 36.34759],
|
||||
[-119.32713, 36.3477],
|
||||
[-119.32708, 36.34776],
|
||||
[-119.327, 36.34782],
|
||||
[-119.327, 36.34782],
|
||||
[-119.32708, 36.34776],
|
||||
[-119.32713, 36.3477],
|
||||
[-119.32713, 36.34759],
|
||||
[-119.32712, 36.34709],
|
||||
[-119.32772, 36.34709],
|
||||
[-119.32775, 36.34709],
|
||||
[-119.32776, 36.3475],
|
||||
[-119.32776, 36.34811],
|
||||
[-119.32777, 36.34917],
|
||||
[-119.32824, 36.34917],
|
||||
[-119.32845, 36.34917],
|
||||
[-119.32885, 36.34917],
|
||||
[-119.33003, 36.34918],
|
||||
[-119.33057, 36.34918],
|
||||
[-119.33075, 36.34918],
|
||||
[-119.3309, 36.34918],
|
||||
[-119.33099, 36.34922],
|
||||
[-119.33116, 36.34922],
|
||||
[-119.33126, 36.34925],
|
||||
[-119.33195, 36.34926],
|
||||
[-119.33197, 36.34976],
|
||||
[-119.33198, 36.35],
|
||||
[-119.33199, 36.35024],
|
||||
[-119.33203, 36.35129],
|
||||
[-119.33201, 36.35191],
|
||||
[-119.33202, 36.35275],
|
||||
[-119.33202, 36.35279],
|
||||
[-119.33202, 36.353],
|
||||
[-119.33203, 36.35327],
|
||||
[-119.33204, 36.35457],
|
||||
[-119.33205, 36.35516],
|
||||
[-119.33205, 36.35532],
|
||||
[-119.33205, 36.3556],
|
||||
[-119.33205, 36.35601],
|
||||
[-119.33198, 36.35611],
|
||||
[-119.33197, 36.35633],
|
||||
[-119.33197, 36.35641],
|
||||
[-119.33197, 36.35657],
|
||||
[-119.33199, 36.35746],
|
||||
[-119.33199, 36.35756],
|
||||
[-119.33202, 36.35785],
|
||||
[-119.33203, 36.35815],
|
||||
[-119.33203, 36.35865],
|
||||
[-119.33203, 36.35903],
|
||||
[-119.3321, 36.35914],
|
||||
[-119.3321, 36.35923],
|
||||
[-119.33209, 36.35952],
|
||||
[-119.33211, 36.36154],
|
||||
[-119.33194, 36.36154],
|
||||
[-119.33114, 36.36153],
|
||||
[-119.33029, 36.36154],
|
||||
[-119.32824, 36.36153],
|
||||
[-119.32824, 36.36165],
|
||||
[-119.32826, 36.36241],
|
||||
[-119.32826, 36.36262],
|
||||
[-119.3283, 36.36284],
|
||||
],
|
||||
},
|
||||
};
|
||||
const stopData = {
|
||||
type: "Feature",
|
||||
geometry: {
|
||||
type: "MultiPoint",
|
||||
coordinates: [
|
||||
[-119.31104, 36.3419],
|
||||
[-119.30438, 36.35753],
|
||||
[-119.327, 36.34782],
|
||||
[-119.3283, 36.36284],
|
||||
],
|
||||
},
|
||||
properties: {},
|
||||
};
|
||||
|
||||
// Add map controls
|
||||
this._map.addControl(new maplibregl.NavigationControl());
|
||||
// Wait for the map to load
|
||||
this._map.on("load", () => {
|
||||
this._map.addSource("route", {
|
||||
type: "geojson",
|
||||
data: routeData,
|
||||
});
|
||||
this._map.addSource("stops", {
|
||||
type: "geojson",
|
||||
data: stopData,
|
||||
});
|
||||
|
||||
// Add a layer to display the route
|
||||
this._map.addLayer({
|
||||
id: "route",
|
||||
type: "line",
|
||||
source: "route",
|
||||
layout: {
|
||||
"line-join": "round",
|
||||
"line-cap": "round",
|
||||
},
|
||||
paint: {
|
||||
"line-color": "#3887be",
|
||||
"line-width": 5,
|
||||
"line-opacity": 0.75,
|
||||
},
|
||||
});
|
||||
|
||||
this._map.addLayer({
|
||||
id: "stops",
|
||||
type: "circle",
|
||||
source: "stops",
|
||||
paint: {
|
||||
"circle-radius": 8,
|
||||
"circle-color": "#f00",
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initial render of component
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
@import url('//unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css');
|
||||
.map-container {
|
||||
background-color: #e9ecef;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
||||
height: 500px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
#map {
|
||||
height: 500px;
|
||||
width:100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
#map img {
|
||||
max-width: none;
|
||||
min-width: 0px;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="map-container" class="map-container">
|
||||
<div id="map"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
addLayer(a) {
|
||||
return this._map.addLayer(a);
|
||||
}
|
||||
addSource(a, b) {
|
||||
return this._map.addSource(a, b);
|
||||
}
|
||||
jumpTo(args) {
|
||||
return this._map.jumpTo(args);
|
||||
}
|
||||
on(a, b) {
|
||||
return this._map.on(a, b);
|
||||
}
|
||||
once(a, b) {
|
||||
return this._map.once(a, b);
|
||||
}
|
||||
queryRenderedFeatures(a) {
|
||||
return this._map.queryRenderedFeatures(a);
|
||||
}
|
||||
|
||||
setMarker(coords) {
|
||||
console.log("Setting map marker", coords);
|
||||
this._map.jumpTo({
|
||||
center: coords,
|
||||
zoom: 14,
|
||||
});
|
||||
this._markers.forEach((marker) => marker.remove());
|
||||
|
||||
const marker = new mapboxgl.Marker({
|
||||
color: "#FF0000",
|
||||
draggable: true,
|
||||
})
|
||||
.setLngLat(coords)
|
||||
.addTo(map);
|
||||
marker.on("dragend", function (e) {
|
||||
const markerDraggedEvent = new CustomEvent("markerdragend", {
|
||||
detail: {
|
||||
marker: marker,
|
||||
},
|
||||
});
|
||||
mapContainer.dispatchEvent(markerDraggedEvent);
|
||||
});
|
||||
this._markers = [marker];
|
||||
}
|
||||
|
||||
SetLayoutProperty(layout, property, value) {
|
||||
return this._map.setLayoutProperty(layout, property, value);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("map-routing", MapRouting);
|
||||
132
static/js/map-service-area.js
Normal file
132
static/js/map-service-area.js
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
// A test of maplibre-gl in a custom element
|
||||
class MapServiceArea extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Create a shadow DOM
|
||||
this.attachShadow({ mode: "open" });
|
||||
|
||||
// Initial render
|
||||
this.render();
|
||||
|
||||
this._map = null;
|
||||
|
||||
// markers shown on the map
|
||||
this._markers = [];
|
||||
}
|
||||
|
||||
// Lifecycle: when element is added to the DOM
|
||||
connectedCallback() {
|
||||
// Initialize the map when the element is added to the DOM
|
||||
setTimeout(() => this._initializeMap(), 0);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._map) {
|
||||
this._map.remove();
|
||||
}
|
||||
}
|
||||
|
||||
_initializeMap() {
|
||||
const centroid = JSON.parse(this.getAttribute("centroid"));
|
||||
const csv_file = this.getAttribute("csv-file");
|
||||
const organization_id = this.getAttribute("organization-id");
|
||||
const lat = Number(this.getAttribute("latitude") || 36.2);
|
||||
const lng = Number(this.getAttribute("longitude") || -119.2);
|
||||
const mapElement = this.shadowRoot.querySelector("#map");
|
||||
const tegola = this.getAttribute("tegola");
|
||||
const xmin = parseFloat(this.getAttribute("xmin"));
|
||||
const ymin = parseFloat(this.getAttribute("ymin"));
|
||||
const xmax = parseFloat(this.getAttribute("xmax"));
|
||||
const ymax = parseFloat(this.getAttribute("ymax"));
|
||||
const bounds = [
|
||||
[xmin, ymin],
|
||||
[xmax, ymax],
|
||||
];
|
||||
console.log("fitting", bounds);
|
||||
this._map = new maplibregl.Map({
|
||||
container: mapElement,
|
||||
center: centroid.coordinates,
|
||||
style: "https://tiles.stadiamaps.com/styles/alidade_smooth.json",
|
||||
}).fitBounds(bounds, {
|
||||
padding: { top: 10, bottom: 10, left: 10, right: 10 },
|
||||
});
|
||||
this._map.on("load", () => {
|
||||
this._map.addSource("tegola-nidus", {
|
||||
type: "vector",
|
||||
tiles: [`${tegola}maps/nidus/{z}/{x}/{y}?id=${organization_id}`],
|
||||
});
|
||||
this._map.addLayer({
|
||||
id: "service-area",
|
||||
source: "tegola-nidus",
|
||||
"source-layer": "service-area-bounds",
|
||||
type: "fill",
|
||||
paint: {
|
||||
"fill-opacity": 0.4,
|
||||
"fill-color": "#dc3545",
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initial render of component
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
@import url('//unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css');
|
||||
.mapboxgl-ctrl-bottom-right {
|
||||
display: none;
|
||||
}
|
||||
.map-container {
|
||||
background-color: #e9ecef;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
||||
height: 500px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
#map {
|
||||
height: 500px;
|
||||
width:100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
#map img {
|
||||
max-width: none;
|
||||
min-width: 0px;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="map-container" class="map-container">
|
||||
<div id="map"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
addLayer(a) {
|
||||
return this._map.addLayer(a);
|
||||
}
|
||||
addSource(a, b) {
|
||||
return this._map.addSource(a, b);
|
||||
}
|
||||
jumpTo(args) {
|
||||
return this._map.jumpTo(args);
|
||||
}
|
||||
on(a, b) {
|
||||
return this._map.on(a, b);
|
||||
}
|
||||
once(a, b) {
|
||||
return this._map.once(a, b);
|
||||
}
|
||||
queryRenderedFeatures(a) {
|
||||
return this._map.queryRenderedFeatures(a);
|
||||
}
|
||||
|
||||
SetLayoutProperty(layout, property, value) {
|
||||
return this._map.setLayoutProperty(layout, property, value);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("map-service-area", MapServiceArea);
|
||||
212
static/js/photo-upload.js
Normal file
212
static/js/photo-upload.js
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
class PhotoUpload extends HTMLElement {
|
||||
// make element form-associated
|
||||
static formAssociated = true;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
// Track all selected files
|
||||
this.selectedFiles = new Map();
|
||||
this.fileCounter = 0;
|
||||
this.render();
|
||||
this.fileInput = this.shadowRoot.getElementById("photos");
|
||||
this.internals = this.attachInternals();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
setTimeout(() => this._initializeUploader(), 0);
|
||||
}
|
||||
|
||||
_initializeUploader() {
|
||||
// Elements
|
||||
const photoInput = this.shadowRoot.querySelector("#photos");
|
||||
|
||||
// Handle photo selection
|
||||
photoInput.addEventListener("change", () => {
|
||||
this._handlePhotoSelection();
|
||||
this._updateFormValue();
|
||||
});
|
||||
|
||||
// Handle drag and drop
|
||||
const photoDropArea = this.shadowRoot.querySelector("#photoDropArea");
|
||||
|
||||
photoDropArea.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
photoDropArea.style.backgroundColor = "#e9ecef";
|
||||
});
|
||||
|
||||
photoDropArea.addEventListener("dragleave", () => {
|
||||
photoDropArea.style.backgroundColor = "#f8f9fa";
|
||||
});
|
||||
|
||||
photoDropArea.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
photoDropArea.style.backgroundColor = "#f8f9fa";
|
||||
|
||||
if (e.dataTransfer.files.length) {
|
||||
this._handleFiles(e.dataTransfer.files);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update form value with all selected files
|
||||
_updateFormValue() {
|
||||
const entries = new FormData();
|
||||
for (const [fileId, file] of this.selectedFiles.entries()) {
|
||||
entries.append(`photo_${fileId}`, file);
|
||||
}
|
||||
this.internals.setFormValue(entries);
|
||||
}
|
||||
|
||||
// Handle files from drag and drop
|
||||
_handleFiles(files) {
|
||||
// Set the files to the input element
|
||||
// (Not directly possible, but we can process them manually)
|
||||
Array.from(files).forEach((file) => {
|
||||
if (file.type.match("image.*")) {
|
||||
const fileId = this.fileCounter++;
|
||||
this.selectedFiles.set(fileId, file);
|
||||
this._createImagePreview(file, fileId);
|
||||
}
|
||||
});
|
||||
|
||||
this._updateFormValue();
|
||||
}
|
||||
|
||||
render() {
|
||||
const style = `
|
||||
<link href="/static/css/bootstrap.css" rel="stylesheet" />
|
||||
<style>
|
||||
.photo-upload-area {
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.photo-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.photo-preview img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
// Create the table
|
||||
let html = `
|
||||
<div class="photo-upload-area">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="bi bi-camera mb-2" viewBox="0 0 16 16">
|
||||
<path d="M15 12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h1.172a3 3 0 0 0 2.12-.879l.83-.828A1 1 0 0 1 6.827 3h2.344a1 1 0 0 1 .707.293l.828.828A3 3 0 0 0 12.828 5H14a1 1 0 0 1 1 1v6zM2 4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-1.172a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 9.172 2H6.828a2 2 0 0 0-1.414.586l-.828.828A2 2 0 0 1 3.172 4H2z"/>
|
||||
<path d="M8 11a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5zm0 1a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7zM3 6.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0z"/>
|
||||
</svg>
|
||||
<div class="file-upload-container" id="photoDropArea">
|
||||
<input type="file" id="photos" name="photos" class="d-none"
|
||||
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp,image/bmp" multiple>
|
||||
<button type="button" class="btn btn-outline-primary mb-2" onclick="this.getRootNode().handleButtonClick()">Add Photos</button>
|
||||
</div>
|
||||
<small class="d-block text-muted">Take pictures of the mosquito problem area</small>
|
||||
|
||||
<!-- Photo Preview Area -->
|
||||
<div id="photoPreviewContainer" class="photo-preview mt-3 d-flex flex-wrap">
|
||||
<!-- Image previews will be added here by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Set the shadow DOM content
|
||||
this.shadowRoot.innerHTML = style + html;
|
||||
this.shadowRoot.handleButtonClick = () => {
|
||||
const photoInput = this.shadowRoot.querySelector("#photos");
|
||||
photoInput.click();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an image preview for a single file
|
||||
*/
|
||||
_createImagePreview(file, fileId) {
|
||||
const photoPreviewContainer = this.shadowRoot.querySelector(
|
||||
"#photoPreviewContainer",
|
||||
);
|
||||
|
||||
// Create preview container
|
||||
const previewContainer = document.createElement("div");
|
||||
previewContainer.className = "position-relative m-1";
|
||||
previewContainer.dataset.fileId = fileId;
|
||||
|
||||
// Create image preview
|
||||
const img = document.createElement("img");
|
||||
img.className = "img-thumbnail";
|
||||
img.style.width = "100px";
|
||||
img.style.height = "100px";
|
||||
img.style.objectFit = "cover";
|
||||
|
||||
// Read file and set preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
img.src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Create remove button
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.type = "button";
|
||||
removeBtn.className = "btn btn-sm btn-danger position-absolute top-0 end-0";
|
||||
removeBtn.innerHTML = "×";
|
||||
removeBtn.style.fontSize = "10px";
|
||||
removeBtn.style.padding = "0 5px";
|
||||
|
||||
// Handle remove button click
|
||||
removeBtn.addEventListener("click", () => {
|
||||
// Remove this file from our collection
|
||||
this.selectedFiles.delete(parseInt(previewContainer.dataset.fileId));
|
||||
// Update the form value
|
||||
this._updateFormValue();
|
||||
// Remove the preview
|
||||
previewContainer.remove();
|
||||
});
|
||||
|
||||
// Add elements to the preview container
|
||||
previewContainer.appendChild(img);
|
||||
previewContainer.appendChild(removeBtn);
|
||||
photoPreviewContainer.appendChild(previewContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle photo selection and preview
|
||||
*/
|
||||
_handlePhotoSelection() {
|
||||
const photoInput = this.shadowRoot.querySelector("#photos");
|
||||
|
||||
// Check if files were selected
|
||||
if (photoInput.files && photoInput.files.length > 0) {
|
||||
// Loop through selected files
|
||||
Array.from(photoInput.files).forEach((file) => {
|
||||
if (!file.type.match("image.*")) {
|
||||
console.log("Skipping non-image file", file.type);
|
||||
return; // Skip non-image files
|
||||
}
|
||||
|
||||
// Add file to our collection with unique ID
|
||||
const fileId = this.fileCounter++;
|
||||
this.selectedFiles.set(fileId, file);
|
||||
|
||||
// Create and add preview
|
||||
this._createImagePreview(file, fileId);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the custom element
|
||||
customElements.define("photo-upload", PhotoUpload);
|
||||
207
static/js/table-report.js
Normal file
207
static/js/table-report.js
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
class TableReport extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this._reports = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the reports data and render the table
|
||||
*/
|
||||
set reports(value) {
|
||||
this._reports = value;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reports data
|
||||
*/
|
||||
get reports() {
|
||||
return this._reports;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get badge color class based on report type
|
||||
*/
|
||||
getTypeClass(type) {
|
||||
switch (type) {
|
||||
case "nuisance":
|
||||
return "bg-danger";
|
||||
case "quick":
|
||||
return "bg-primary";
|
||||
case "water":
|
||||
return "bg-success";
|
||||
default:
|
||||
return "bg-secondary";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get badge color class based on report status
|
||||
*/
|
||||
getStatusClass(status) {
|
||||
switch (status) {
|
||||
case "Reported":
|
||||
return "bg-warning text-dark";
|
||||
case "Assigned":
|
||||
return "bg-info text-dark";
|
||||
case "On-Hold":
|
||||
return "bg-secondary";
|
||||
case "Complete":
|
||||
return "bg-success";
|
||||
default:
|
||||
return "bg-secondary";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the report ID with hyphens
|
||||
*/
|
||||
formatId(id) {
|
||||
if (id.length === 12) {
|
||||
return `${id.substring(0, 4)}-${id.substring(4, 8)}-${id.substring(8)}`;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
render() {
|
||||
// Create the styles
|
||||
const style = `
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.table {
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.table-light {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.table-hover tbody tr:hover {
|
||||
background-color: rgba(0, 0, 0, 0.075);
|
||||
}
|
||||
th, td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
text-align: left;
|
||||
}
|
||||
.clickable-row {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
.clickable-row:hover {
|
||||
background-color: rgba(13, 110, 253, 0.1);
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.35em 0.65em;
|
||||
font-size: 0.75em;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.bg-danger {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
.bg-primary {
|
||||
background-color: #0d6efd;
|
||||
}
|
||||
.bg-success {
|
||||
background-color: #198754;
|
||||
}
|
||||
.bg-warning {
|
||||
background-color: #ffc107;
|
||||
}
|
||||
.bg-info {
|
||||
background-color: #0dcaf0;
|
||||
}
|
||||
.bg-secondary {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
.report-type-badge {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.text-dark {
|
||||
color: #212529 !important;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
// Create the table
|
||||
let tableHTML = `
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th scope="col">Report ID</th>
|
||||
<th scope="col">Reported</th>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Address</th>
|
||||
<th scope="col">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="report-table-body">
|
||||
`;
|
||||
|
||||
// Generate rows for each report
|
||||
if (this._reports.length > 0) {
|
||||
this._reports.forEach((report) => {
|
||||
const typeClass = this.getTypeClass(report.type);
|
||||
const statusClass = this.getStatusClass(report.status);
|
||||
const formattedId = this.formatId(report.id);
|
||||
|
||||
tableHTML += `
|
||||
<tr class="clickable-row" data-report-id="${report.id}">
|
||||
<td><strong>${formattedId}</strong></td>
|
||||
<td><time-relative time="${report.created}"</time-relative></td>
|
||||
<td><span class="badge ${typeClass} report-type-badge">${report.type}</span></td>
|
||||
<td>${report.address || "N/A"}</td>
|
||||
<td><span class="badge ${statusClass}">${report.status}</span></td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
} else {
|
||||
tableHTML += `
|
||||
<tr><td colspan="5">No reports</td></tr>
|
||||
`;
|
||||
}
|
||||
|
||||
tableHTML += `
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
// Set the shadow DOM content
|
||||
this.shadowRoot.innerHTML = style + tableHTML;
|
||||
// Add click handlers for the rows
|
||||
this.shadowRoot.querySelectorAll("tr.clickable-row").forEach((el) => {
|
||||
el.addEventListener("click", (e) => {
|
||||
let element = e.target;
|
||||
while (element.nodeName != "TR") {
|
||||
element = element.parentElement;
|
||||
}
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("row-clicked", {
|
||||
bubbles: true,
|
||||
composed: true, // Allows event to cross shadow DOM boundary
|
||||
detail: {
|
||||
reportId: element.dataset.reportId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Register the custom element
|
||||
customElements.define("table-report", TableReport);
|
||||
173
static/js/table-site.js
Normal file
173
static/js/table-site.js
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
class TableSite extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this._sites = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the sites data and render the table
|
||||
*/
|
||||
set sites(value) {
|
||||
this._sites = value;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sites data
|
||||
*/
|
||||
get sites() {
|
||||
return this._sites;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get badge color class based on report status
|
||||
*/
|
||||
getConditionClass(status) {
|
||||
switch (status) {
|
||||
case "Reported":
|
||||
return "bg-warning text-dark";
|
||||
case "Assigned":
|
||||
return "bg-info text-dark";
|
||||
case "On-Hold":
|
||||
return "bg-secondary";
|
||||
case "Complete":
|
||||
return "bg-success";
|
||||
default:
|
||||
return "bg-secondary";
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
// Create the styles
|
||||
const style = `
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.table {
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.table-light {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.table-hover tbody tr:hover {
|
||||
background-color: rgba(0, 0, 0, 0.075);
|
||||
}
|
||||
th, td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
text-align: left;
|
||||
}
|
||||
.clickable-row {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
.clickable-row:hover {
|
||||
background-color: rgba(13, 110, 253, 0.1);
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.35em 0.65em;
|
||||
font-size: 0.75em;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.bg-danger {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
.bg-primary {
|
||||
background-color: #0d6efd;
|
||||
}
|
||||
.bg-success {
|
||||
background-color: #198754;
|
||||
}
|
||||
.bg-warning {
|
||||
background-color: #ffc107;
|
||||
}
|
||||
.bg-info {
|
||||
background-color: #0dcaf0;
|
||||
}
|
||||
.bg-secondary {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
.report-type-badge {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.text-dark {
|
||||
color: #212529 !important;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
// Create the table
|
||||
let tableHTML = `
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th scope="col">Site ID</th>
|
||||
<th scope="col">Condition</th>
|
||||
<th scope="col">Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="report-table-body">
|
||||
`;
|
||||
|
||||
// Generate rows for each report
|
||||
if (this._sites.length > 0) {
|
||||
this._sites.forEach((site) => {
|
||||
tableHTML += `
|
||||
<tr class="clickable-row" data-site-id="${site.id}">
|
||||
<td><strong>${site.id}</strong></td>
|
||||
<td>${site.condition}</td>
|
||||
<td>${site.address}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
} else {
|
||||
tableHTML += `
|
||||
<tr><td colspan="3">No sites</td></tr>
|
||||
`;
|
||||
}
|
||||
|
||||
tableHTML += `
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
// Set the shadow DOM content
|
||||
this.shadowRoot.innerHTML = style + tableHTML;
|
||||
// Add click handlers for the rows
|
||||
this.shadowRoot.querySelectorAll("tr.clickable-row").forEach((el) => {
|
||||
el.addEventListener("click", (e) => {
|
||||
let element = e.target;
|
||||
while (element.nodeName != "TR") {
|
||||
element = element.parentElement;
|
||||
}
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("row-clicked", {
|
||||
bubbles: true,
|
||||
composed: true, // Allows event to cross shadow DOM boundary
|
||||
detail: {
|
||||
reportId: element.dataset.reportId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Register the custom element
|
||||
customElements.define("table-site", TableSite);
|
||||
92
static/js/time-relative.js
Normal file
92
static/js/time-relative.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* Custom HTML element <time-relative> that displays relative time
|
||||
* Usage: <time-relative time="2024-01-01T12:00:00Z"></time-relative>
|
||||
*/
|
||||
|
||||
class TimeRelative extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.span = null;
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return ["time"];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Create the span element if it doesn't exist
|
||||
if (!this.span) {
|
||||
this.span = document.createElement("span");
|
||||
this.span.className = "time-relative";
|
||||
this.appendChild(this.span);
|
||||
}
|
||||
this.updateTime();
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
if (name === "time" && oldValue !== newValue) {
|
||||
this.updateTime();
|
||||
}
|
||||
}
|
||||
|
||||
updateTime() {
|
||||
if (this.span) {
|
||||
const timeValue = this.getAttribute("time");
|
||||
if (timeValue) {
|
||||
this.span.textContent = this.formatRelativeTime(timeValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formatRelativeTime(timestamp) {
|
||||
const now = new Date();
|
||||
const date = new Date(timestamp);
|
||||
const diffInSeconds = Math.floor((now - date) / 1000);
|
||||
|
||||
// Time units in seconds
|
||||
const minute = 60;
|
||||
const hour = minute * 60;
|
||||
const day = hour * 24;
|
||||
const week = day * 7;
|
||||
const month = day * 30;
|
||||
const year = day * 365;
|
||||
|
||||
if (diffInSeconds < minute) {
|
||||
return "just now";
|
||||
} else if (diffInSeconds < hour) {
|
||||
const minutes = Math.floor(diffInSeconds / minute);
|
||||
return `${minutes} ${minutes === 1 ? "min" : "min"} ago`;
|
||||
} else if (diffInSeconds < day) {
|
||||
const hours = Math.floor(diffInSeconds / hour);
|
||||
return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
|
||||
} else if (diffInSeconds < week) {
|
||||
const days = Math.floor(diffInSeconds / day);
|
||||
return `${days} ${days === 1 ? "day" : "days"} ago`;
|
||||
} else if (diffInSeconds < month) {
|
||||
const weeks = Math.floor(diffInSeconds / week);
|
||||
return `${weeks} ${weeks === 1 ? "week" : "weeks"} ago`;
|
||||
} else if (diffInSeconds < year) {
|
||||
const months = Math.floor(diffInSeconds / month);
|
||||
return `${months} ${months === 1 ? "month" : "months"} ago`;
|
||||
} else {
|
||||
const years = Math.floor(diffInSeconds / year);
|
||||
return `${years} ${years === 1 ? "year" : "years"} ago`;
|
||||
}
|
||||
}
|
||||
|
||||
// Property getter and setter for JavaScript access
|
||||
get time() {
|
||||
return this.getAttribute("time");
|
||||
}
|
||||
|
||||
set time(value) {
|
||||
if (value) {
|
||||
this.setAttribute("time", value);
|
||||
} else {
|
||||
this.removeAttribute("time");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the custom element
|
||||
customElements.define("time-relative", TimeRelative);
|
||||
243
static/js/user-selector.js
Normal file
243
static/js/user-selector.js
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
class UserSelector extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.selectedUser = null;
|
||||
this.debounceTimer = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<link href="/static/css/bootstrap.css" rel="stylesheet">
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.suggestions-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.suggestions-dropdown.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.suggestion-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.suggestion-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.user-display-name {
|
||||
font-weight: 500;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.user-username {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.user-org {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="user-selector-container">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Type to search users (min. 4 characters)..."
|
||||
id="userInput"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<div class="suggestions-dropdown card shadow-sm" id="suggestionsDropdown">
|
||||
<div class="list-group list-group-flush" id="suggestionsList">
|
||||
<!-- Suggestions will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const input = this.shadowRoot.getElementById("userInput");
|
||||
const dropdown = this.shadowRoot.getElementById("suggestionsDropdown");
|
||||
|
||||
input.addEventListener("input", (e) => this.handleInput(e));
|
||||
input.addEventListener("focus", (e) => {
|
||||
if (e.target.value.length >= 4) {
|
||||
this.handleInput(e);
|
||||
}
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!this.contains(e.target)) {
|
||||
this.hideSuggestions();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleInput(e) {
|
||||
const query = e.target.value;
|
||||
|
||||
// Clear previous timer
|
||||
clearTimeout(this.debounceTimer);
|
||||
|
||||
if (query.length < 4) {
|
||||
this.hideSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce API calls
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.fetchSuggestions(query);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async fetchSuggestions(query) {
|
||||
const suggestionsList = this.shadowRoot.getElementById("suggestionsList");
|
||||
const dropdown = this.shadowRoot.getElementById("suggestionsDropdown");
|
||||
|
||||
// Show loading state
|
||||
suggestionsList.innerHTML = '<div class="loading">Loading...</div>';
|
||||
dropdown.classList.add("show");
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/user/suggestion?query=${encodeURIComponent(query)}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.displaySuggestions(data.users);
|
||||
} catch (error) {
|
||||
console.error("Error fetching suggestions:", error);
|
||||
suggestionsList.innerHTML = `
|
||||
<div class="alert alert-danger m-2" role="alert">
|
||||
Error loading suggestions. Please try again.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
displaySuggestions(users) {
|
||||
const suggestionsList = this.shadowRoot.getElementById("suggestionsList");
|
||||
const dropdown = this.shadowRoot.getElementById("suggestionsDropdown");
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
suggestionsList.innerHTML = `
|
||||
<div class="loading">No users found</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
suggestionsList.innerHTML = users
|
||||
.map(
|
||||
(user) => `
|
||||
<div class="list-group-item list-group-item-action suggestion-item" data-user='${JSON.stringify(user)}'>
|
||||
<div class="d-flex w-100 justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<div class="user-display-name">${this.escapeHtml(user.display_name)}</div>
|
||||
<div class="user-username">@${this.escapeHtml(user.username)}</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<span class="badge bg-secondary user-org">${this.escapeHtml(user.organization.name)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
// Add click handlers to suggestion items
|
||||
suggestionsList.querySelectorAll(".suggestion-item").forEach((item) => {
|
||||
item.addEventListener("click", (e) => {
|
||||
const userData = JSON.parse(e.currentTarget.getAttribute("data-user"));
|
||||
this.selectUser(userData);
|
||||
});
|
||||
});
|
||||
|
||||
dropdown.classList.add("show");
|
||||
}
|
||||
|
||||
selectUser(user) {
|
||||
this.selectedUser = user;
|
||||
const input = this.shadowRoot.getElementById("userInput");
|
||||
input.value = user.displayName || user.display_name;
|
||||
this.hideSuggestions();
|
||||
|
||||
// Dispatch custom event
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("user-selected", {
|
||||
detail: { user },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
hideSuggestions() {
|
||||
const dropdown = this.shadowRoot.getElementById("suggestionsDropdown");
|
||||
dropdown.classList.remove("show");
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const map = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
return text.replace(/[&<>"']/g, (m) => map[m]);
|
||||
}
|
||||
|
||||
// Public method to get selected user
|
||||
getSelectedUser() {
|
||||
return this.selectedUser;
|
||||
}
|
||||
|
||||
// Public method to clear selection
|
||||
clear() {
|
||||
this.selectedUser = null;
|
||||
const input = this.shadowRoot.getElementById("userInput");
|
||||
input.value = "";
|
||||
this.hideSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
// Register the custom element
|
||||
customElements.define("user-selector", UserSelector);
|
||||
Loading…
Add table
Add a link
Reference in a new issue