Move static outside HTML. Start work on TypeScript bundle

It's not strictly HTML, so that's just correct.

This is just worth doing while building the new TypeScript bundle
This commit is contained in:
Eli Ribble 2026-03-21 03:06:59 +00:00
parent 976a29b7d7
commit 31947c848a
No known key found for this signature in database
53 changed files with 7100 additions and 73 deletions

View file

@ -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.")

View file

@ -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":

View file

@ -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")
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View file

@ -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,,,,,,
1 Street Address City Zip Property Owner Name Resident Owned Resident Phone Number Pool Condition Notes Recurrant New Hostile Unresponsive
2 123 Main Street Visalia 93615 John Smith Yes 1235556789 Empty Pool collects runoff, dry by summer Yes No No Yes
3 456 Valley View Dr Los Angeles 93618 Jane and Jim Blackner No 2345550055 Green Pool murky at beginning of season No Yes No No
4 11235 Fibonacci Rd San Francisco 93618 Warren Buffet No 3455551212

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

View file

@ -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);

View file

@ -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);

View file

@ -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);

File diff suppressed because it is too large Load diff

View file

@ -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";
}
}

View file

@ -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;
}

View file

@ -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
);
});
}

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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 = "&times;";
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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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 = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
};
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);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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 = {};

View file

@ -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 }};

View file

@ -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>