PageSourceSearch

https://droneskycheck-d0136.web.app/js/assistant/assistant-ui.js?v=__VERSION__

js droneskycheck-d0136.web.app collected 2026-10-03 12:25:10 UTC 33,961 bytes, 1,065 lines download raw bytes

1(function () {
2  "use strict";
3
4  window.DSC = window.DSC || {};
5  const Assistant = window.DSC.Assistant = window.DSC.Assistant || {};
6
7  const QUICK_PROMPTS_GENERAL = [
8    "Cos'e' la categoria Open?",
9    "Serve l'attestato A1/A3?",
10    "Come funziona Drone Sky Check?"
11  ];
12
13  const QUICK_PROMPTS_WITH_LOCATION = [
14    "Posso volare qui?",
15    "Quali limitazioni ci sono?",
16    "Serve un'autorizzazione?",
17    "Qual e' la quota massima?"
18  ];
19
20  const DESKTOP_MEDIA_QUERY = "(min-width: 761px)";
21  const PANEL_VIEWPORT_MARGIN = 12;
22  const AVATAR_ASSET_URL = String(
23    window.DSC_ASSISTANT_AVATAR_URL || "img/assistant/dsc-assistant-avatar.png"
24  ).trim();
25
26  let refs = {};
27  let dragState = null;
28  let resizeRaf = 0;
29  let desktopMedia = null;
30  let quotaRequest = null;
31  let clearDialogOpen = false;
32  let clearDialogReturnFocus = null;
33
34  function el(tag, options = {}, children = []) {
35    const node = document.createElement(tag);
36
37    Object.entries(options).forEach(([key, value]) => {
38      if (value === null || value === undefined) return;
39
40      if (key === "className") {
41        node.className = value;
42      } else if (key === "text") {
43        node.textContent = value;
44      } else if (key === "html") {
45        node.innerHTML = value;
46      } else if (key === "dataset") {
47        Object.entries(value).forEach(([dataKey, dataValue]) => {
48          node.dataset[dataKey] = dataValue;
49        });
50      } else {
51        node.setAttribute(key, value);
52      }
53    });
54
55    children.forEach(child => {
56      if (child) node.appendChild(child);
57    });
58
59    return node;
60  }
61
62  function commandIcon(name, className = "") {
63    const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
64    icon.setAttribute("class", `dsc-icon ${className}`.trim());
65    icon.setAttribute("aria-hidden", "true");
66    const use = document.createElementNS("http://www.w3.org/2000/svg", "use");
67    use.setAttribute("href", `icons/dsc-ui-icons.svg#dsc-icon-${name}`);
68    icon.appendChild(use);
69    return icon;
70  }
71
72  function assistantAvatar(className) {
73    const avatar = el("span", {
74      className: className + " dsc-assistant-avatar-frame",
75      "aria-hidden": "true"
76    });
77    const fallback = el("span", { className: "dsc-assistant-avatar-fallback", text: "DSC" });
78    const image = el("img", {
79      className: "dsc-assistant-avatar-image",
80      alt: "",
81      draggable: "false",
82      decoding: "async"
83    });
84
85    image.addEventListener("load", () => {
86      image.hidden = false;
87      avatar.classList.add("has-image");
88    });
89    image.addEventListener("error", () => {
90      image.hidden = true;
91      image.removeAttribute("src");
92      avatar.classList.remove("has-image");
93    });
94
95    avatar.appendChild(fallback);
96    avatar.appendChild(image);
97    if (AVATAR_ASSET_URL) image.src = AVATAR_ASSET_URL;
98    return avatar;
99  }
100
101  function buildShell() {
102    const root = document.getElementById("dsc-assistant-root") || document.body.appendChild(el("div", { id: "dsc-assistant-root" }));
103
104    const fab = el("button", {
105      id: "dsc-assistant-fab",
106      className: "dsc-assistant-fab",
107      type: "button",
108      title: "Assistente DSC",
109      "aria-label": "Apri Assistente DSC",
110      "aria-expanded": "false",
111      "aria-controls": "dsc-assistant-panel"
112    }, [
113      assistantAvatar("dsc-assistant-fab-avatar"),
114      el("span", { className: "dsc-assistant-fab-status", "aria-hidden": "true" })
115    ]);
116
117    const clearButton = el("button", {
118      className: "dsc-assistant-icon-button dsc-assistant-clear-button",
119      type: "button",
120      title: "Cancella conversazione",
121      "aria-label": "Cancella conversazione",
122      "aria-haspopup": "dialog",
123      "aria-controls": "dsc-assistant-clear-confirmation",
124      "aria-expanded": "false"
125    }, [commandIcon("rotate-ccw", "dsc-assistant-reset-icon")]);
126
127    const closeButton = el("button", {
128      className: "dsc-assistant-icon-button",
129      type: "button",
130      title: "Chiudi",
131      "aria-label": "Chiudi Assistente DSC"
132    }, [commandIcon("x")]);
133
134    const quotaIndicator = el("div", {
135      className: "dsc-assistant-quota",
136      "aria-live": "polite",
137      text: "Crediti AI in verifica"
138    });
139
140    const panel = el("section", {
141      id: "dsc-assistant-panel",
142      className: "dsc-assistant-panel hidden",
143      role: "dialog",
144      "aria-modal": "false",
145      "aria-labelledby": "dsc-assistant-title"
146    });
147
148    const dragGrip = el("div", {
149      className: "dsc-assistant-drag-zone",
150      role: "button",
151      tabindex: "0",
152      title: "Sposta pannello",
153      "aria-label": "Sposta pannello Assistente DSC. Usa il trascinamento o le frecce."
154    }, [
155      el("span", { className: "dsc-assistant-drag-grip", "aria-hidden": "true" })
156    ]);
157
158    const messages = el("div", {
159      id: "dsc-assistant-messages",
160      className: "dsc-assistant-messages",
161      "aria-live": "polite"
162    });
163
164    const suggestions = el("div", {
165      className: "dsc-assistant-suggestions",
166      "aria-label": "Domande rapide"
167    });
168
169    const input = el("textarea", {
170      id: "dsc-assistant-input",
171      className: "dsc-assistant-input",
172      rows: "1",
173      maxlength: "700",
174      placeholder: "Scrivi una domanda..."
175    });
176
177    const sendButton = el("button", {
178      className: "dsc-assistant-send",
179      type: "submit",
180      title: "Invia",
181      "aria-label": "Invia messaggio"
182    }, [el("span", { "aria-hidden": "true", text: ">" })]);
183
184    const form = el("form", { className: "dsc-assistant-compose" }, [
185      input,
186      sendButton
187    ]);
188
189    const contextBar = el("div", { className: "dsc-assistant-context hidden" }, [
190      el("span", { className: "dsc-assistant-context-dot", "aria-hidden": "true" }),
191      el("span", { className: "dsc-assistant-context-label", text: "Nessuna posizione selezionata" })
192    ]);
193
194    const clearCancelButton = el("button", {
195      className: "dsc-assistant-confirm-button",
196      type: "button",
197      text: "Annulla"
198    });
199    const clearConfirmButton = el("button", {
200      className: "dsc-assistant-confirm-button is-danger",
201      type: "button",
202      text: "Cancella"
203    });
204    const clearConfirmation = el("div", {
205      id: "dsc-assistant-clear-confirmation",
206      className: "dsc-assistant-confirmation hidden",
207      role: "alertdialog",
208      "aria-modal": "true",
209      "aria-labelledby": "dsc-assistant-clear-title",
210      "aria-hidden": "true"
211    }, [
212      el("div", { className: "dsc-assistant-confirmation-box" }, [
213        el("p", {
214          id: "dsc-assistant-clear-title",
215          className: "dsc-assistant-confirmation-title",
216          text: "Vuoi cancellare questa conversazione?"
217        }),
218        el("div", { className: "dsc-assistant-confirmation-actions" }, [
219          clearCancelButton,
220          clearConfirmButton
221        ])
222      ])
223    ]);
224
225    panel.appendChild(dragGrip);
226    panel.appendChild(el("header", { className: "dsc-assistant-header" }, [
227      assistantAvatar("dsc-assistant-avatar"),
228      el("div", { className: "dsc-assistant-heading" }, [
229        el("h2", { id: "dsc-assistant-title", text: "Assistente DSC" }),
230        el("p", { text: "Assistente di Drone Sky Check" }),
231        quotaIndicator
232      ]),
233      el("div", { className: "dsc-assistant-header-actions" }, [
234        clearButton,
235        closeButton
236      ])
237    ]));
238    panel.appendChild(contextBar);
239    panel.appendChild(messages);
240    panel.appendChild(suggestions);
241    panel.appendChild(form);
242    panel.appendChild(clearConfirmation);
243
244    root.appendChild(fab);
245    root.appendChild(panel);
246
247    refs = {
248      root,
249      fab,
250      panel,
251      dragGrip,
252      clearButton,
253      closeButton,
254      clearConfirmation,
255      clearCancelButton,
256      clearConfirmButton,
257      quotaIndicator,
258      messages,
259      suggestions,
260      contextBar,
261      input,
262      sendButton,
263      form
264    };
265  }
266
267  function stopMapInteractions() {
268    [refs.fab, refs.panel].forEach(node => {
269      if (!node) return;
270
271      node.addEventListener("click", event => event.stopPropagation());
272      node.addEventListener("dblclick", event => event.stopPropagation());
273      node.addEventListener("pointerdown", event => event.stopPropagation());
274
275      try {
276        window.L?.DomEvent?.disableClickPropagation?.(node);
277        window.L?.DomEvent?.disableScrollPropagation?.(node);
278      } catch (err) {
279        console.warn("[DSC Assistant] map propagation guard failed", err);
280      }
281    });
282  }
283
284  function ensureWelcomeMessage() {
285    if (Assistant.Storage?.hasSeenWelcome?.()) return;
286    Assistant.Storage?.markWelcomeSeen?.();
287  }
288
289  function openAssistant() {
290    ensureWelcomeMessage();
291    window.DSC?.Insights?.adapters?.toolOpened?.("DSC_ASSISTANT", "ASSISTANT");
292    Assistant.State.setOpen(true);
293    syncPanelLayout();
294    refreshQuota();
295
296    window.setTimeout(() => {
297      refs.input?.focus();
298    }, 80);
299  }
300
301  function closeAssistant() {
302    closeClearConfirmation({ restoreFocus: false });
303    Assistant.State.setOpen(false);
304  }
305
306  function toggleAssistant() {
307    const current = Assistant.State.getState();
308    if (current.isOpen) {
309      closeAssistant();
310    } else {
311      openAssistant();
312    }
313  }
314
315  function openClearConfirmation() {
316    const state = Assistant.State.getState();
317    if (state.isBusy || !state.messages.length || clearDialogOpen) return;
318
319    clearDialogOpen = true;
320    clearDialogReturnFocus = document.activeElement;
321    refs.clearConfirmation.classList.remove("hidden");
322    refs.clearConfirmation.setAttribute("aria-hidden", "false");
323    refs.clearButton.setAttribute("aria-expanded", "true");
324    window.setTimeout(() => refs.clearCancelButton?.focus(), 0);
325  }
326
327  function closeClearConfirmation(options = {}) {
328    if (!clearDialogOpen || !refs.clearConfirmation) return;
329
330    const restoreFocus = options.restoreFocus !== false;
331    const focusTarget = clearDialogReturnFocus;
332    clearDialogOpen = false;
333    clearDialogReturnFocus = null;
334    refs.clearConfirmation.classList.add("hidden");
335    refs.clearConfirmation.setAttribute("aria-hidden", "true");
336    refs.clearButton?.setAttribute("aria-expanded", "false");
337
338    if (restoreFocus && focusTarget?.focus) {
339      window.setTimeout(() => focusTarget.focus(), 0);
340    }
341  }
342
343  function confirmClearConversation() {
344    if (Assistant.State.getState().isBusy) {
345      closeClearConfirmation();
346      return;
347    }
348
349    closeClearConfirmation({ restoreFocus: false });
350    Assistant.State.clearConversation();
351    window.setTimeout(() => refs.input?.focus(), 0);
352  }
353
354  function handleClearDialogKeydown(event) {
355    if (!clearDialogOpen) return;
356    if (event.key === "Escape") {
357      event.preventDefault();
358      event.stopPropagation();
359      closeClearConfirmation();
360      return;
361    }
362    if (event.key !== "Tab") return;
363
364    const focusable = [refs.clearCancelButton, refs.clearConfirmButton].filter(Boolean);
365    const currentIndex = focusable.indexOf(document.activeElement);
366    const nextIndex = event.shiftKey
367      ? (currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1)
368      : (currentIndex >= focusable.length - 1 ? 0 : currentIndex + 1);
369    event.preventDefault();
370    focusable[nextIndex]?.focus();
371  }
372
373  function isDesktopLayout() {
374    desktopMedia = desktopMedia || window.matchMedia(DESKTOP_MEDIA_QUERY);
375    return desktopMedia.matches;
376  }
377
378  function getViewportSize() {
379    return {
380      width: window.innerWidth || document.documentElement.clientWidth || 0,
381      height: window.innerHeight || document.documentElement.clientHeight || 0
382    };
383  }
384
385  function getPanelSize() {
386    const rect = refs.panel.getBoundingClientRect();
387    return {
388      width: rect.width,
389      height: rect.height
390    };
391  }
392
393  function clamp(value, min, max) {
394    if (max < min) return min;
395    return Math.min(Math.max(value, min), max);
396  }
397
398  function clampPanelPosition(position, size = getPanelSize(), viewport = getViewportSize()) {
399    const left = Number(position?.left);
400    const top = Number(position?.top);
401    const panelWidth = Number(size?.width) || 0;
402    const panelHeight = Number(size?.height) || 0;
403    const maxLeft = Math.max(PANEL_VIEWPORT_MARGIN, viewport.width - panelWidth - PANEL_VIEWPORT_MARGIN);
404    const maxTop = Math.max(PANEL_VIEWPORT_MARGIN, viewport.height - panelHeight - PANEL_VIEWPORT_MARGIN);
405
406    return {
407      left: clamp(Number.isFinite(left) ? left : maxLeft, PANEL_VIEWPORT_MARGIN, maxLeft),
408      top: clamp(Number.isFinite(top) ? top : PANEL_VIEWPORT_MARGIN, PANEL_VIEWPORT_MARGIN, maxTop)
409    };
410  }
411
412  function getDefaultPanelPosition() {
413    const viewport = getViewportSize();
414    const size = getPanelSize();
415    const topbarHeight = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--topbarHeight")) || 0;
416
417    return clampPanelPosition({
418      left: viewport.width - size.width - 18,
419      top: topbarHeight + 18
420    }, size, viewport);
421  }
422
423  function applyPanelPosition(position, options = {}) {
424    if (!refs.panel || !isDesktopLayout()) return null;
425
426    const viewport = getViewportSize();
427    const clamped = clampPanelPosition(position, getPanelSize(), viewport);
428
429    refs.panel.style.left = clamped.left + "px";
430    refs.panel.style.top = clamped.top + "px";
431    refs.panel.style.right = "auto";
432    refs.panel.style.bottom = "auto";
433
434    if (options.persist === true) {
435      Assistant.Storage?.savePanelPosition?.(clamped, viewport);
436    }
437
438    return clamped;
439  }
440
441  function resetPanelFreePosition() {
442    if (!refs.panel) return;
443
444    refs.panel.style.left = "";
445    refs.panel.style.top = "";
446    refs.panel.style.right = "";
447    refs.panel.style.bottom = "";
448  }
449
450  function syncPanelLayout() {
451    if (!refs.panel || refs.panel.classList.contains("hidden")) return;
452
453    if (!isDesktopLayout()) {
454      cancelPanelDrag({ persist: false });
455      resetPanelFreePosition();
456      return;
457    }
458
459    const savedPosition = Assistant.Storage?.getPanelPosition?.();
460
461    if (savedPosition) {
462      const clamped = applyPanelPosition(savedPosition, { persist: false });
463      if (
464        clamped &&
465        (Math.abs(clamped.left - savedPosition.left) > 0.5 || Math.abs(clamped.top - savedPosition.top) > 0.5)
466      ) {
467        Assistant.Storage?.savePanelPosition?.(clamped, getViewportSize());
468      }
469      return;
470    }
471
472    applyPanelPosition(getDefaultPanelPosition(), { persist: false });
473  }
474
475  function beginPanelDrag(event) {
476    if (!isDesktopLayout()) return;
477    if (event.button !== undefined && event.button !== 0) return;
478
479    event.preventDefault();
480    event.stopPropagation();
481
482    const rect = refs.panel.getBoundingClientRect();
483    dragState = {
484      pointerId: event.pointerId,
485      offsetX: event.clientX - rect.left,
486      offsetY: event.clientY - rect.top
487    };
488
489    try {
490      refs.dragGrip.setPointerCapture?.(event.pointerId);
491    } catch (err) {
492      console.warn("[DSC Assistant] pointer capture unavailable", err);
493    }
494    refs.panel.classList.add("is-dragging");
495    refs.dragGrip.classList.add("is-dragging");
496    document.body.classList.add("dsc-assistant-dragging");
497
498    window.addEventListener("pointermove", movePanelDrag);
499    window.addEventListener("pointerup", endPanelDrag);
500    window.addEventListener("pointercancel", cancelPanelDrag);
501  }
502
503  function movePanelDrag(event) {
504    if (!dragState || event.pointerId !== dragState.pointerId) return;
505
506    event.preventDefault();
507    event.stopPropagation();
508
509    applyPanelPosition({
510      left: event.clientX - dragState.offsetX,
511      top: event.clientY - dragState.offsetY
512    }, { persist: true });
513  }
514
515  function endPanelDrag(event) {
516    if (!dragState || event.pointerId !== dragState.pointerId) return;
517
518    event.preventDefault();
519    event.stopPropagation();
520    cancelPanelDrag({ persist: true });
521  }
522
523  function cancelPanelDrag(options = {}) {
524    if (!dragState) return;
525
526    const pointerId = dragState.pointerId;
527    dragState = null;
528
529    try {
530      refs.dragGrip?.releasePointerCapture?.(pointerId);
531    } catch (err) {
532      console.warn("[DSC Assistant] pointer release unavailable", err);
533    }
534    refs.panel?.classList.remove("is-dragging");
535    refs.dragGrip?.classList.remove("is-dragging");
536    document.body.classList.remove("dsc-assistant-dragging");
537
538    window.removeEventListener("pointermove", movePanelDrag);
539    window.removeEventListener("pointerup", endPanelDrag);
540    window.removeEventListener("pointercancel", cancelPanelDrag);
541
542    if (options.persist === true && refs.panel && isDesktopLayout()) {
543      const rect = refs.panel.getBoundingClientRect();
544      applyPanelPosition({ left: rect.left, top: rect.top }, { persist: true });
545    }
546  }
547
548  function schedulePanelResizeSync() {
549    if (resizeRaf) {
550      window.cancelAnimationFrame(resizeRaf);
551    }
552
553    resizeRaf = window.requestAnimationFrame(() => {
554      resizeRaf = 0;
555      syncPanelLayout();
556      window.setTimeout(syncPanelLayout, 120);
557    });
558  }
559
560  function handleGripKeydown(event) {
561    if (!isDesktopLayout()) return;
562
563    const directions = {
564      ArrowLeft: [-1, 0],
565      ArrowRight: [1, 0],
566      ArrowUp: [0, -1],
567      ArrowDown: [0, 1]
568    };
569    const direction = directions[event.key];
570    if (!direction) return;
571
572    event.preventDefault();
573    event.stopPropagation();
574
575    const rect = refs.panel.getBoundingClientRect();
576    const step = event.shiftKey ? 64 : 16;
577
578    applyPanelPosition({
579      left: rect.left + direction[0] * step,
580      top: rect.top + direction[1] * step
581    }, { persist: true });
582  }
583
584  function appendInlineContent(parent, text) {
585    const value = String(text || "");
586    const pattern = /(\*\*([^*]+)\*\*)|\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
587    let lastIndex = 0;
588    let match;
589
590    while ((match = pattern.exec(value)) !== null) {
591      if (match.index > lastIndex) {
592        parent.appendChild(document.createTextNode(value.slice(lastIndex, match.index)));
593      }
594
595      if (match[2]) {
596        const strong = el("strong");
597        strong.textContent = match[2];
598        parent.appendChild(strong);
599      } else if (match[3] && match[4]) {
600        let url = null;
601        try {
602          url = new URL(match[4]);
603        } catch (err) {
604          url = null;
605        }
606
607        if (url && (url.protocol === "http:" || url.protocol === "https:")) {
608          const link = el("a", {
609            href: url.href,
610            target: "_blank",
611            rel: "noopener noreferrer"
612          });
613          link.textContent = match[3];
614          parent.appendChild(link);
615        } else {
616          parent.appendChild(document.createTextNode(match[0]));
617        }
618      }
619
620      lastIndex = pattern.lastIndex;
621    }
622
623    if (lastIndex < value.length) {
624      parent.appendChild(document.createTextNode(value.slice(lastIndex)));
625    }
626  }
627
628  function appendParagraph(container, lines) {
629    const text = lines.join(" ").trim();
630    if (!text) return;
631
632    const paragraph = el("p");
633    appendInlineContent(paragraph, text);
634    container.appendChild(paragraph);
635  }
636
637  function renderMarkdownText(text) {
638    const container = el("div", { className: "dsc-assistant-richtext" });
639    const lines = String(text || "").replace(/\r\n/g, "\n").split("\n");
640    let paragraphLines = [];
641    let list = null;
642
643    function flushParagraph() {
644      appendParagraph(container, paragraphLines);
645      paragraphLines = [];
646    }
647
648    function flushList() {
649      list = null;
650    }
651
652    lines.forEach(line => {
653      const trimmed = line.trim();
654      const bullet = /^[-*]\s+(.+)$/.exec(trimmed);
655      const numbered = /^\d+[.)]\s+(.+)$/.exec(trimmed);
656
657      if (!trimmed) {
658        flushParagraph();
659        flushList();
660        return;
661      }
662
663      if (bullet || numbered) {
664        flushParagraph();
665        const tag = bullet ? "ul" : "ol";
666
667        if (!list || list.tagName.toLowerCase() !== tag) {
668          list = el(tag);
669          container.appendChild(list);
670        }
671
672        const item = el("li");
673        appendInlineContent(item, bullet ? bullet[1] : numbered[1]);
674        list.appendChild(item);
675        return;
676      }
677
678      flushList();
679      paragraphLines.push(trimmed);
680    });
681
682    flushParagraph();
683    return container;
684  }
685
686  function getSourceTitle(source) {
687    return source.title || source.document || source.authority || "Fonte";
688  }
689
690  function renderSources(sources) {
691    const visibleSources = Array.isArray(sources) ? sources.filter(Boolean) : [];
692    if (!visibleSources.length) return null;
693
694    const details = el("details", { className: "dsc-assistant-sources" }, [
695      el("summary", { text: "Fonti (" + visibleSources.length + ")" })
696    ]);
697    const list = el("ul");
698
699    visibleSources.forEach(source => {
700      const item = el("li");
701      const title = el("span", { className: "dsc-assistant-source-title", text: getSourceTitle(source) });
702      item.appendChild(title);
703
704      const meta = [
705        source.group,
706        source.authority && source.authority !== getSourceTitle(source) ? source.authority : "",
707        source.document && source.document !== getSourceTitle(source) ? source.document : "",
708        source.section,
709        source.page ? "pag. " + source.page : ""
710      ].filter(Boolean).join(" · ");
711
712      if (meta) {
713        item.appendChild(el("span", { className: "dsc-assistant-source-meta", text: meta }));
714      }
715
716      list.appendChild(item);
717    });
718
719    details.appendChild(list);
720    return details;
721  }
722
723  function renderOperationalDetails(detailsValue) {
724    if (!detailsValue || typeof detailsValue !== "object") return null;
725    const details = el("details", { className: "dsc-assistant-operational-details" });
726    details.appendChild(el("summary", { text: detailsValue.label || "Dettagli" }));
727
728    const content = el("div", { className: "dsc-assistant-operational-details-content" });
729    if (detailsValue.text) {
730      content.appendChild(el("p", { text: detailsValue.text }));
731    }
732    if (detailsValue.action?.type === "OPEN_ZONE_DETAILS") {
733      const button = el("button", {
734        className: "dsc-assistant-zone-details-button",
735        type: "button",
736        text: detailsValue.action.label || "Dettagli zone"
737      });
738      button.addEventListener("click", () => Assistant.Context?.openZoneDetails?.());
739      content.appendChild(button);
740    }
741    details.appendChild(content);
742    return details;
743  }
744
745  function countdownFromIso(value) {
746    const targetMs = Date.parse(value || "");
747    if (!Number.isFinite(targetMs)) return null;
748
749    const seconds = Math.max(0, Math.floor((targetMs - Date.now()) / 1000));
750    if (seconds <= 0) return null;
751
752    const minutes = Math.floor(seconds / 60);
753    const remainingSeconds = seconds % 60;
754    return minutes > 0 ? minutes + "m " + remainingSeconds + "s" : remainingSeconds + "s";
755  }
756
757  function getQuotaResetText(quota, status) {
758    if (status === "loading") return "Sto verificando i crediti disponibili.";
759    if (status === "unavailable") return "Al momento non riesco a leggere la disponibilita' crediti.";
760    if (!quota) return "La disponibilita' crediti viene letta dal backend.";
761    if (quota.unlimited) return "Modalita' test: utilizzo AI illimitato.";
762
763    const nextCredit = countdownFromIso(quota.nextCreditAt);
764    if (quota.remaining <= 0 && nextCredit) {
765      return "Crediti esauriti: prossimo credito tra " + nextCredit + ".";
766    }
767
768    const reset = countdownFromIso(quota.resetAt);
769    if (reset) {
770      return "Disponibili " + quota.remaining + "/" + quota.capacity + "; ripristino indicato tra " + reset + ".";
771    }
772
773    if (quota.period) {
774      const period = quota.period === "DAILY" ? "giornaliero" : quota.period.toLowerCase();
775      const zone = quota.timezone ? " (" + quota.timezone + ")" : "";
776      return "Disponibili " + quota.remaining + "/" + quota.capacity + "; ripristino " + period + zone + ".";
777    }
778
779    return "Disponibili " + quota.remaining + "/" + quota.capacity + "; i crediti si rigenerano automaticamente.";
780  }
781
782  function formatQuota(quota, status) {
783    if (status === "loading") return "Crediti AI in verifica";
784    if (status === "unavailable") return "Crediti AI non disponibili";
785    if (!quota) return "Crediti AI in verifica";
786    if (quota.unlimited) return "Modalita' test · utilizzo AI illimitato";
787    if (quota.remaining <= 0) {
788      const countdown = countdownFromIso(quota.nextCreditAt) || countdownFromIso(quota.resetAt);
789      if (countdown) return "Crediti 0/" + quota.capacity + " · prossimo tra " + countdown;
790    }
791    return "Crediti " + quota.remaining + "/" + quota.capacity;
792  }
793
794  function isQuotaExhausted(quota) {
795    return quota && quota.unlimited !== true && Number(quota.remaining) <= 0;
796  }
797
798  function refreshQuota() {
799    if (quotaRequest || !Assistant.Client?.quota) return quotaRequest;
800
801    Assistant.State.setQuotaStatus("loading");
802    quotaRequest = Assistant.Client.quota()
803      .then(quota => {
804        Assistant.State.setQuota(quota);
805        return quota;
806      })
807      .catch(err => {
808        console.warn("[DSC Assistant] quota unavailable", err);
809        Assistant.State.setQuotaStatus("unavailable");
810        return null;
811      })
812      .finally(() => {
813        quotaRequest = null;
814      });
815
816    return quotaRequest;
817  }
818
819  function renderIntro(state) {
820    const intro = el("section", { className: "dsc-assistant-intro" });
821
822    intro.appendChild(el("div", { className: "dsc-assistant-intro-title", text: "Assistente DSC" }));
823    intro.appendChild(el("p", {
824      text: "Puoi chiedermi informazioni su normativa UAS, attestati, categorie operative, droni e uso di Drone Sky Check."
825    }));
826
827    if (state.selectedLocation) {
828      intro.appendChild(el("p", {
829        text: "Hai selezionato un punto sulla mappa: posso usarlo per domande come \"Posso volare qui?\" o \"Qual e' la quota massima?\"."
830      }));
831    } else {
832      intro.appendChild(el("p", {
833        text: "Puoi anche selezionare un punto sulla mappa e poi chiedermi, per esempio, \"Posso volare qui?\"."
834      }));
835    }
836
837    intro.appendChild(el("p", {
838      className: "dsc-assistant-intro-credit",
839      text: "Le domande usano i crediti dell'Assistente. " + getQuotaResetText(state.quota, state.quotaStatus)
840    }));
841    intro.appendChild(el("p", {
842      className: "dsc-assistant-intro-note",
843      text: "Funzione sperimentale: verifica sempre le fonti ufficiali prima di volare."
844    }));
845
846    return intro;
847  }
848
849  function renderMessages(state) {
850    const messages = state.messages;
851    const isBusy = state.isBusy;
852    refs.messages.replaceChildren();
853
854    if (!messages.length) {
855      refs.messages.appendChild(renderIntro(state));
856    }
857
858    messages.forEach(message => {
859      const item = el("article", {
860        className: "dsc-assistant-message " +
861          (message.role === "user" ? "is-user" : "is-assistant") +
862          (message.error ? " is-error" : "")
863      });
864
865      item.appendChild(el("div", { className: "dsc-assistant-message-role", text: message.role === "user" ? "Tu" : "Assistente DSC" }));
866
867      const body = el("div", { className: "dsc-assistant-message-text" });
868      body.appendChild(message.role === "assistant" ? renderMarkdownText(message.text) : document.createTextNode(message.text));
869      item.appendChild(body);
870
871      const sources = renderSources(message.sources);
872      if (sources) item.appendChild(sources);
873
874      const operationalDetails = renderOperationalDetails(message.operationalDetails);
875      if (operationalDetails) item.appendChild(operationalDetails);
876
877      refs.messages.appendChild(item);
878    });
879
880    if (isBusy) {
881      refs.messages.appendChild(el("div", { className: "dsc-assistant-typing", text: "Sto verificando..." }));
882    }
883
884    refs.messages.scrollTop = refs.messages.scrollHeight;
885  }
886
887  function renderSuggestions(location) {
888    const prompts = location
889      ? QUICK_PROMPTS_WITH_LOCATION.concat(QUICK_PROMPTS_GENERAL)
890      : QUICK_PROMPTS_GENERAL;
891    refs.suggestions.replaceChildren();
892
893    prompts.forEach(prompt => {
894      const button = el("button", {
895        className: "dsc-assistant-chip",
896        type: "button",
897        text: prompt
898      });
899      button.addEventListener("click", () => submitPrompt(prompt));
900      refs.suggestions.appendChild(button);
901    });
902  }
903
904  function renderContext(location) {
905    const label = refs.contextBar?.querySelector(".dsc-assistant-context-label");
906
907    if (!location) {
908      refs.contextBar.classList.add("hidden");
909      if (label) label.textContent = "Nessuna posizione selezionata";
910      return;
911    }
912
913    refs.contextBar.classList.remove("hidden");
914    if (label) {
915      label.textContent = "Sto considerando il punto selezionato sulla mappa";
916    }
917  }
918
919  function render(state) {
920    refs.panel.classList.toggle("hidden", !state.isOpen);
921    refs.fab.classList.toggle("active", state.isOpen);
922    refs.fab.setAttribute("aria-expanded", String(state.isOpen));
923    refs.sendButton.disabled = state.isBusy || !state.draft.trim() || isQuotaExhausted(state.quota);
924    refs.clearButton.disabled = state.isBusy || state.messages.length === 0;
925    refs.input.disabled = state.isBusy;
926    if (refs.quotaIndicator) {
927      refs.quotaIndicator.textContent = formatQuota(state.quota, state.quotaStatus);
928      refs.quotaIndicator.classList.toggle("is-empty", isQuotaExhausted(state.quota));
929      refs.quotaIndicator.classList.toggle("is-unavailable", state.quotaStatus === "unavailable");
930    }
931    document.body.classList.toggle("dsc-assistant-open", state.isOpen);
932
933    if (refs.input.value !== state.draft) {
934      refs.input.value = state.draft;
935    }
936
937    renderContext(state.selectedLocation);
938    renderMessages(state);
939    renderSuggestions(state.selectedLocation);
940
941    if (state.isOpen && !dragState) {
942      syncPanelLayout();
943    }
944  }
945
946  async function submitPrompt(rawPrompt) {
947    const current = Assistant.State.getState();
948    const conversationVersion = current.conversationVersion;
949    const query = String(rawPrompt || "").trim();
950    if (!query || current.isBusy) return;
951
952    if (isQuotaExhausted(current.quota)) {
953      Assistant.State.addMessage({
954        role: "assistant",
955        text: Assistant.Client.userMessageForError({ code: "AI_QUOTA_EXHAUSTED", quota: current.quota }),
956        error: true
957      });
958      return;
959    }
960
961    Assistant.State.addMessage({ role: "user", text: query });
962    Assistant.State.setDraft("");
963    Assistant.State.setBusy(true);
964
965    try {
966      const response = await Assistant.Client.answer({ query });
967      if (response.quota) {
968        Assistant.State.setQuota(response.quota);
969      }
970      if (!Assistant.State.isConversationCurrent(conversationVersion)) return;
971      if (response.operationalContext) {
972        Assistant.State.setLastOperationalContext(response.operationalContext);
973      }
974      Assistant.State.addMessage({
975        role: "assistant",
976        text: response.text,
977        sources: response.sources,
978        operationalDetails: response.operationalDetails,
979        kind: response.kind
980      });
981    } catch (err) {
982      if (err?.quota) {
983        Assistant.State.setQuota(err.quota);
984      }
985      if (!Assistant.State.isConversationCurrent(conversationVersion)) return;
986      Assistant.State.setError(err?.code || "Risposta non disponibile");
987      Assistant.State.addMessage({
988        role: "assistant",
989        text: Assistant.Client.userMessageForError(err),
990        error: true
991      });
992    } finally {
993      if (Assistant.State.isConversationCurrent(conversationVersion)) {
994        Assistant.State.setBusy(false);
995        window.setTimeout(() => refs.input?.focus(), 30);
996      }
997    }
998  }
999
1000  function bindEvents() {
1001    refs.fab.addEventListener("click", toggleAssistant);
1002    refs.dragGrip.addEventListener("pointerdown", beginPanelDrag);
1003    refs.dragGrip.addEventListener("keydown", handleGripKeydown);
1004    refs.clearButton.addEventListener("click", openClearConfirmation);
1005    refs.closeButton.addEventListener("click", closeAssistant);
1006    refs.clearCancelButton.addEventListener("click", () => closeClearConfirmation());
1007    refs.clearConfirmButton.addEventListener("click", confirmClearConversation);
1008    refs.clearConfirmation.addEventListener("keydown", handleClearDialogKeydown);
1009
1010    refs.form.addEventListener("submit", event => {
1011      event.preventDefault();
1012      submitPrompt(refs.input.value);
1013    });
1014
1015    refs.input.addEventListener("input", event => {
1016      Assistant.State.setDraft(event.target.value);
1017      event.target.style.height = "auto";
1018      event.target.style.height = Math.min(event.target.scrollHeight, 112) + "px";
1019    });
1020
1021    refs.input.addEventListener("keydown", event => {
1022      if (event.key === "Enter" && !event.shiftKey) {
1023        event.preventDefault();
1024        submitPrompt(refs.input.value);
1025      }
1026    });
1027
1028    document.addEventListener("keydown", event => {
1029      if (event.key === "Escape" && Assistant.State.getState().isOpen) {
1030        if (clearDialogOpen) return;
1031        closeAssistant();
1032      }
1033    });
1034
1035    Assistant.Context?.bindZoneDecisionUpdates?.((location) => {
1036      Assistant.State.setSelectedLocation(location);
1037      Assistant.State.setSelectedDrone(Assistant.Context.readSelectedDrone()
1037);
1038    });
1039
1040    Assistant.State.subscribe(render);
1041
1042    window.addEventListener("resize", schedulePanelResizeSync);
1043    window.addEventListener("orientationchange", schedulePanelResizeSync);
1044
1045    desktopMedia = desktopMedia || window.matchMedia(DESKTOP_MEDIA_QUERY);
1046    desktopMedia.addEventListener?.("change", schedulePanelResizeSync);
1047  }
1048
1049  function init() {
1050    if (!Assistant.State || !Assistant.Storage || !Assistant.Client || !Assistant.Context) {
1051      console.warn("[DSC Assistant] base modules unavailable");
1052      return;
1053    }
1054
1055    buildShell();
1056    stopMapInteractions();
1057    bindEvents();
1058  }
1059
1060  if (document.readyState === "loading") {
1061    document.addEventListener("DOMContentLoaded", init, { once: true });
1062  } else {
1063    init();
1064  }
1065})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.