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
|
|
@ -11,6 +11,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/Gleipnir-Technology/nidus-sync/config"
|
||||
"github.com/Gleipnir-Technology/nidus-sync/static"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
|
@ -19,6 +20,15 @@ import (
|
|||
// static files from a http.FileSystem.
|
||||
var startedTime time.Time = time.Now()
|
||||
|
||||
var localFS http.Dir
|
||||
|
||||
func AddStaticRoute(r chi.Router, path string) {
|
||||
if localFS == "" {
|
||||
localFS = http.Dir("./static")
|
||||
}
|
||||
fileServer(r, "/static", localFS, static.EmbeddedStaticFS, "static")
|
||||
}
|
||||
|
||||
func fileServer(r chi.Router, path string, root http.FileSystem, embeddedFS embed.FS, embeddedPath string) {
|
||||
if strings.ContainsAny(path, "{}*") {
|
||||
panic("FileServer does not permit any URL parameters.")
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gleipnir-Technology/nidus-sync/static"
|
||||
"github.com/aarondl/opt/null"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
|
@ -22,6 +23,8 @@ import (
|
|||
func addFuncMap(t *template.Template) {
|
||||
funcMap := template.FuncMap{
|
||||
"bigNumber": bigNumber,
|
||||
"bundlePathCSS": bundlePathCSS,
|
||||
"bundlePathJS": bundlePathJS,
|
||||
"displayUploadStatus": displayUploadStatus,
|
||||
"displayUploadType": displayUploadType,
|
||||
"duration": duration,
|
||||
|
|
@ -59,6 +62,12 @@ func bigNumber(n int) string {
|
|||
|
||||
return result.String()
|
||||
}
|
||||
func bundlePathCSS() string {
|
||||
return static.BundlePathCSS
|
||||
}
|
||||
func bundlePathJS() string {
|
||||
return static.BundlePathJS
|
||||
}
|
||||
func displayUploadStatus(s string) string {
|
||||
switch s {
|
||||
case "committed":
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var EmbeddedStaticFS embed.FS
|
||||
|
||||
var localFS http.Dir
|
||||
|
||||
func AddStaticRoute(r chi.Router, path string) {
|
||||
if localFS == "" {
|
||||
localFS = http.Dir("./html/static")
|
||||
}
|
||||
fileServer(r, "/static", localFS, EmbeddedStaticFS, "static")
|
||||
}
|
||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
|
@ -1,4 +0,0 @@
|
|||
Street Address,City,Zip,Property Owner Name,Resident Owned,Resident Phone Number,Pool Condition,Notes,Recurrant,New,Hostile,Unresponsive
|
||||
123 Main Street,Visalia,93615,John Smith,Yes,1235556789,Empty,"Pool collects runoff, dry by summer",Yes,No,No,Yes
|
||||
456 Valley View Dr,Los Angeles,93618,Jane and Jim Blackner,No,2345550055,Green,Pool murky at beginning of season,No,Yes,No,No
|
||||
11235 Fibonacci Rd,San Francisco,93618,Warren Buffet,No,3455551212,,,,,,
|
||||
|
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
|
@ -1,127 +0,0 @@
|
|||
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);
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
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);
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
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
html/static/js/alpine-3.15.8-min.js
vendored
|
|
@ -1,127 +0,0 @@
|
|||
// 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
// 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);
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
// 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);
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
// 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);
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
// 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);
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
// 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);
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
// 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);
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
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);
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
// 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);
|
||||
|
|
@ -1,408 +0,0 @@
|
|||
// 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);
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
// 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);
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
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);
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
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);
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
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);
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
/**
|
||||
* 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);
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
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);
|
||||
7
html/static/vendor/css/bootstrap.min.css
vendored
BIN
html/static/vendor/font/bootstrap-icons.woff2
vendored
7
html/static/vendor/js/bootstrap.min.js
vendored
|
|
@ -8,8 +8,7 @@
|
|||
<link href="/static/css/bootstrap.css" rel="stylesheet" />
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="/static/vendor/css/bootstrap-icons.min.css" />
|
||||
<!-- favicon -->
|
||||
<link rel="icon" href="/static/favicon-rmo.ico" type="image/x-icon" />
|
||||
<link rel="icon" href="/static/ico/favicon-rmo.ico" type="image/x-icon" />
|
||||
{{ block "extraheader" . }}{{ end }}
|
||||
<script>
|
||||
var tooltipByElementId = {};
|
||||
|
|
|
|||
|
|
@ -4,14 +4,12 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{ template "title" . }} - Nidus Sync</title>
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="/static/css/bootstrap.css" rel="stylesheet" />
|
||||
<link href="{{ bundlePathCSS }}" rel="stylesheet" />
|
||||
<!-- Fontawesome Icons -->
|
||||
<link rel="stylesheet" href="/static/vendor/css/bootstrap-icons.min.css" />
|
||||
<!-- favicon -->
|
||||
<link rel="icon" href="/static/favicon-sync.ico" type="image/x-icon" />
|
||||
<link rel="icon" href="/static/ico/favicon-sync.ico" type="image/x-icon" />
|
||||
<script src="/static/js/events.js"></script>
|
||||
<script defer src="/static/js/alpine-3.15.8-min.js"></script>
|
||||
<script src="{{ bundlePathJS }}" defer></script>
|
||||
{{ block "extraheader" . }}{{ end }}
|
||||
<script>
|
||||
const USER = {{ .User.AsJSON|json }};
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
<link href="/static/vendor/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<!-- Bootstrap Icons -->
|
||||
<link href="/static/css/bootstrap.css" rel="stylesheet" />
|
||||
<!-- favicon -->
|
||||
<link rel="icon" href="/static/favicon-sync.ico" type="image/x-icon" />
|
||||
<link rel="icon" href="/static/ico/favicon-sync.ico" type="image/x-icon" />
|
||||
{{ block "extraheader" . }}{{ end }}
|
||||
{{ if not .Config.IsProductionEnvironment }}
|
||||
<script src="/.flogo/injector.js"></script>
|
||||
|
|
|
|||