Initial commit: sito NexStudio (Astro) con Cloudflare, CMS cookie e contenuti legali
- Home: hero, prodotti, servizi, stack, FAQ, CTA con form contatti inline (glossy) e particelle - Header nero, menu con scroll-spy e alone su voce attiva, CTA Chattiamo (pre-chat) - Pagine: privacy, cookie, GDPR, terms, about, codice etico, modello organizzativo, dove siamo, news - Cookie consent first-party, chat gate, stampe-friendly su SubpageLayout - Footer: Sezioni con Contattaci, Azienda con Dove siamo senza link per ora - .gitignore: aggiunto .wrangler/ per stato locale Cloudflare Made-with: Cursor
This commit is contained in:
27
src/components/CardBottomRightTexture.astro
Normal file
27
src/components/CardBottomRightTexture.astro
Normal file
@ -0,0 +1,27 @@
|
||||
---
|
||||
/**
|
||||
* Metà inferiore card: globo spostato in basso a destra così che ~15–20% resti
|
||||
* fuori dall’area utile ma sia tagliato da overflow-hidden sulla card.
|
||||
*/
|
||||
import WireframeGlobeSvg from './WireframeGlobeSvg.astro';
|
||||
---
|
||||
|
||||
<div
|
||||
class="pointer-events-none absolute right-0 bottom-0 left-0 top-1/2 z-0 overflow-hidden rounded-b-[inherit]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!--
|
||||
translate ~18%: parte del globo va oltre bordo dx/basso; overflow-hidden sulla card la nasconde.
|
||||
-->
|
||||
<WireframeGlobeSvg
|
||||
class="absolute bottom-0 right-0 h-full w-auto max-w-none origin-bottom-right
|
||||
translate-x-[18%] translate-y-[18%]
|
||||
select-none opacity-[0.42] mix-blend-screen
|
||||
min-[500px]:translate-x-[19%] min-[500px]:translate-y-[19%] min-[500px]:opacity-[0.5]
|
||||
sm:translate-x-[20%] sm:translate-y-[20%]"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-gradient-to-t from-transparent to-nx-elevated/20"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
187
src/components/ChatGateModal.astro
Normal file
187
src/components/ChatGateModal.astro
Normal file
@ -0,0 +1,187 @@
|
||||
---
|
||||
import { chat } from '../data/home';
|
||||
|
||||
const pc = chat.prechat;
|
||||
const hasFormspree =
|
||||
typeof pc.actionUrl === 'string' && pc.actionUrl.length > 0;
|
||||
const nextUrl =
|
||||
typeof pc.successRedirect === 'string' && pc.successRedirect.length > 0
|
||||
? pc.successRedirect
|
||||
: '';
|
||||
const notifySubject =
|
||||
typeof pc.notifySubject === 'string' && pc.notifySubject.length > 0
|
||||
? pc.notifySubject
|
||||
: '';
|
||||
|
||||
const model = {
|
||||
entryUrl: chat.entryUrl,
|
||||
openInNewTab: chat.openInNewTab,
|
||||
actionUrl: pc.actionUrl,
|
||||
mailto: pc.mailto,
|
||||
notifySubject: pc.notifySubject,
|
||||
successRedirect: pc.successRedirect,
|
||||
fieldName: pc.fieldName,
|
||||
fieldEmail: pc.fieldEmail,
|
||||
openChatAfterSubmit: pc.openChatAfterSubmit,
|
||||
};
|
||||
---
|
||||
|
||||
<script
|
||||
type="application/json"
|
||||
id="nx-chat-gate-model"
|
||||
set:html={JSON.stringify(model)}
|
||||
/>
|
||||
|
||||
<div
|
||||
id="nx-chat-gate-overlay"
|
||||
class="fixed inset-0 z-[203] flex items-center justify-center bg-black/70 p-4 sm:p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="nx-chat-gate-title"
|
||||
aria-hidden="true"
|
||||
hidden
|
||||
>
|
||||
<div
|
||||
class="flex max-h-[min(92vh,40rem)] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-nx-border bg-nx-elevated text-nx-fg shadow-2xl"
|
||||
data-nx-chat-gate-panel
|
||||
>
|
||||
<div class="border-b border-nx-border px-5 py-4">
|
||||
<h2 id="nx-chat-gate-title" class="text-lg font-semibold tracking-tight">
|
||||
{pc.title}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-nx-muted">{pc.intro}</p>
|
||||
<div
|
||||
class="mt-4 rounded-lg border border-sky-500/25 bg-sky-500/10 px-3 py-2.5"
|
||||
role="status"
|
||||
>
|
||||
<p
|
||||
class="text-xs font-semibold uppercase tracking-wide text-sky-200/95"
|
||||
>
|
||||
{chat.availability.badge}
|
||||
</p>
|
||||
<p class="mt-1.5 text-sm leading-relaxed text-nx-muted">
|
||||
{chat.availability.body}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div id="nx-chat-gate-step-form">
|
||||
<form
|
||||
id="nx-chat-gate-form"
|
||||
class="flex flex-col gap-4"
|
||||
method="post"
|
||||
action={hasFormspree ? pc.actionUrl : '#'}
|
||||
accept-charset="UTF-8"
|
||||
>
|
||||
{
|
||||
hasFormspree && nextUrl ? (
|
||||
<input type="hidden" name="_next" value={nextUrl} />
|
||||
) : null
|
||||
}
|
||||
{
|
||||
hasFormspree && notifySubject ? (
|
||||
<input type="hidden" name="_subject" value={notifySubject} />
|
||||
) : null
|
||||
}
|
||||
{
|
||||
hasFormspree ? (
|
||||
<input
|
||||
type="text"
|
||||
name="_gotcha"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute -left-[9999px] h-0 w-0 opacity-0"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-medium text-nx-muted"
|
||||
for="nx-cg-name"
|
||||
>
|
||||
{pc.nameLabel}
|
||||
</label>
|
||||
<input
|
||||
id="nx-cg-name"
|
||||
type="text"
|
||||
name={pc.fieldName}
|
||||
autocomplete="name"
|
||||
class="mt-1.5 min-h-10 w-full rounded-lg border border-nx-border bg-nx-bg/80 px-3 text-sm text-nx-fg outline-none ring-sky-500/30 focus:border-sky-500/40 focus:ring-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-medium text-nx-muted"
|
||||
for="nx-cg-email"
|
||||
>
|
||||
{pc.emailLabel}
|
||||
</label>
|
||||
<input
|
||||
id="nx-cg-email"
|
||||
type="email"
|
||||
name={pc.fieldEmail}
|
||||
required
|
||||
autocomplete="email"
|
||||
class="mt-1.5 min-h-10 w-full rounded-lg border border-nx-border bg-nx-bg/80 px-3 text-sm text-nx-fg outline-none ring-sky-500/30 focus:border-sky-500/40 focus:ring-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
id="nx-chat-gate-form-error"
|
||||
class="hidden text-sm leading-relaxed text-red-400/90"
|
||||
role="alert"
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col-reverse gap-2 pt-1 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
data-nx-chat-gate-close
|
||||
class="h-10 rounded-lg border border-nx-border px-4 text-sm font-medium text-nx-muted hover:text-nx-fg"
|
||||
>
|
||||
{pc.closeLabel}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex h-10 items-center justify-center rounded-lg bg-nx-fg px-5 text-sm font-semibold text-nx-bg hover:bg-white"
|
||||
>
|
||||
{pc.submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="nx-chat-gate-step-success" class="hidden">
|
||||
<p
|
||||
id="nx-chat-gate-success-text"
|
||||
class="text-sm leading-relaxed text-nx-muted"
|
||||
>
|
||||
{pc.successMessage}
|
||||
</p>
|
||||
<div class="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
data-nx-chat-gate-close
|
||||
class="h-10 rounded-lg border border-nx-border px-4 text-sm font-medium text-nx-muted hover:text-nx-fg"
|
||||
>
|
||||
{pc.closeLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="nx-chat-gate-open-chat"
|
||||
class="inline-flex h-10 items-center justify-center rounded-lg bg-nx-fg px-5 text-sm font-semibold text-nx-bg hover:bg-white"
|
||||
>
|
||||
{pc.enterChatLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import '../scripts/nx-chat-gate-modal.ts';
|
||||
</script>
|
||||
196
src/components/ContactFormInline.astro
Normal file
196
src/components/ContactFormInline.astro
Normal file
@ -0,0 +1,196 @@
|
||||
---
|
||||
import { cta } from '../data/home';
|
||||
|
||||
const cf = cta.contactForm;
|
||||
const model = {
|
||||
actionUrl: cf.actionUrl,
|
||||
mailto: cf.mailto,
|
||||
notifySubject: cf.notifySubject,
|
||||
successRedirect: cf.successRedirect,
|
||||
fieldName: cf.fieldName,
|
||||
fieldCompany: cf.fieldCompany,
|
||||
fieldCountry: cf.fieldCountry,
|
||||
fieldEmail: cf.fieldEmail,
|
||||
fieldMessage: cf.fieldMessage,
|
||||
};
|
||||
const hasFormspree =
|
||||
typeof cf.actionUrl === 'string' && cf.actionUrl.length > 0;
|
||||
const nextUrl =
|
||||
typeof cf.successRedirect === 'string' && cf.successRedirect.length > 0
|
||||
? cf.successRedirect
|
||||
: '';
|
||||
const notifySubject =
|
||||
typeof cf.notifySubject === 'string' && cf.notifySubject.length > 0
|
||||
? cf.notifySubject
|
||||
: '';
|
||||
const fieldClass =
|
||||
'mt-1.5 min-h-10 w-full rounded-lg border border-nx-border bg-nx-bg/80 px-3 text-sm text-nx-fg outline-none ring-sky-500/30 focus:border-sky-500/40 focus:ring-2';
|
||||
---
|
||||
|
||||
<script
|
||||
type="application/json"
|
||||
id="nx-contact-model"
|
||||
set:html={JSON.stringify(model)}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="nx-contact-glossy group relative rounded-2xl p-px shadow-[0_0_0_1px_rgba(255,255,255,0.07),0_22px_60px_-18px_rgba(0,0,0,0.55),0_0_50px_-10px_rgba(56,189,248,0.22),0_0_80px_-20px_rgba(167,139,250,0.12)]"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-br from-white/35 via-sky-400/25 to-violet-500/30 opacity-90"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute -inset-px rounded-2xl bg-gradient-to-tl from-sky-500/10 via-transparent to-violet-500/15 opacity-70 blur-sm"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="relative overflow-hidden rounded-2xl border border-white/10 bg-nx-elevated/65 p-4 shadow-[inset_0_1px_0_0_rgba(255,255,255,0.14),inset_0_-1px_0_0_rgba(0,0,0,0.12)] backdrop-blur-md sm:p-5"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/50 to-transparent"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute -right-6 -top-8 h-28 w-28 rounded-full bg-sky-400/15 blur-2xl"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute -bottom-10 -left-4 h-32 w-40 rounded-full bg-violet-500/10 blur-3xl"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1]">
|
||||
<h3
|
||||
id="nx-contact-form-title"
|
||||
class="text-base font-semibold tracking-tight text-nx-fg drop-shadow-sm"
|
||||
>
|
||||
{cf.title}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs leading-relaxed text-nx-muted sm:text-sm">
|
||||
{cf.description}
|
||||
</p>
|
||||
|
||||
<form
|
||||
id="nx-contact-form"
|
||||
class="relative mt-4 space-y-3.5"
|
||||
method="post"
|
||||
action={hasFormspree ? cf.actionUrl : '#'}
|
||||
accept-charset="UTF-8"
|
||||
aria-labelledby="nx-contact-form-title"
|
||||
>
|
||||
{
|
||||
hasFormspree && nextUrl ? (
|
||||
<input type="hidden" name="_next" value={nextUrl} />
|
||||
) : null
|
||||
}
|
||||
{
|
||||
hasFormspree && notifySubject ? (
|
||||
<input type="hidden" name="_subject" value={notifySubject} />
|
||||
) : null
|
||||
}
|
||||
{
|
||||
hasFormspree ? (
|
||||
<input
|
||||
type="text"
|
||||
name="_gotcha"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute -left-[9999px] h-0 w-0 opacity-0"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-nx-muted" for="nx-cf-name">
|
||||
{cf.nameLabel}
|
||||
</label>
|
||||
<input
|
||||
id="nx-cf-name"
|
||||
type="text"
|
||||
name={cf.fieldName}
|
||||
required
|
||||
autocomplete="name"
|
||||
class={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-nx-muted" for="nx-cf-company">
|
||||
{cf.companyLabel}
|
||||
</label>
|
||||
<input
|
||||
id="nx-cf-company"
|
||||
type="text"
|
||||
name={cf.fieldCompany}
|
||||
autocomplete="organization"
|
||||
class={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-nx-muted" for="nx-cf-email">
|
||||
{cf.emailLabel}
|
||||
</label>
|
||||
<input
|
||||
id="nx-cf-email"
|
||||
type="email"
|
||||
name={cf.fieldEmail}
|
||||
required
|
||||
autocomplete="email"
|
||||
class={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-nx-muted" for="nx-cf-country">
|
||||
{cf.countryLabel}
|
||||
</label>
|
||||
<input
|
||||
id="nx-cf-country"
|
||||
type="text"
|
||||
name={cf.fieldCountry}
|
||||
required
|
||||
autocomplete="country-name"
|
||||
class={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-nx-muted" for="nx-cf-msg">
|
||||
{cf.messageLabel}
|
||||
</label>
|
||||
<textarea
|
||||
id="nx-cf-msg"
|
||||
name={cf.fieldMessage}
|
||||
required
|
||||
rows={4}
|
||||
class="mt-1.5 min-h-[6.5rem] w-full resize-y rounded-lg border border-nx-border bg-nx-bg/80 px-3 py-2 text-sm leading-relaxed text-nx-fg outline-none ring-sky-500/30 focus:border-sky-500/40 focus:ring-2"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<p
|
||||
id="nx-contact-form-error"
|
||||
class="hidden text-sm leading-relaxed text-red-400/90"
|
||||
role="alert"
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex h-10 w-full items-center justify-center rounded-lg bg-nx-fg px-5 text-sm font-semibold text-nx-bg hover:bg-white sm:w-auto sm:min-w-[10rem]"
|
||||
>
|
||||
{cf.submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import '../scripts/nx-contact-form.ts';
|
||||
</script>
|
||||
156
src/components/CookieConsent.astro
Normal file
156
src/components/CookieConsent.astro
Normal file
@ -0,0 +1,156 @@
|
||||
---
|
||||
import { cookieConsentMeta, cookieCategories } from '../data/cookie-consent';
|
||||
|
||||
const model = {
|
||||
storageKey: cookieConsentMeta.storageKey,
|
||||
policyVersion: cookieConsentMeta.policyVersion,
|
||||
categories: cookieCategories.map((c) => ({ id: c.id, required: c.required })),
|
||||
};
|
||||
---
|
||||
|
||||
<script
|
||||
type="application/json"
|
||||
id="nx-cc-model"
|
||||
set:html={JSON.stringify(model)}
|
||||
/>
|
||||
<div
|
||||
id="nx-cc-banner"
|
||||
class="fixed inset-x-0 bottom-0 z-[200] border-t border-nx-border bg-nx-elevated/95 px-4 py-4 shadow-[0_-12px_40px_rgba(0,0,0,0.45)] backdrop-blur-xl sm:px-6"
|
||||
role="region"
|
||||
aria-labelledby="nx-cc-banner-title"
|
||||
aria-describedby="nx-cc-banner-desc"
|
||||
aria-hidden="true"
|
||||
hidden
|
||||
>
|
||||
<div class="mx-auto flex max-w-6xl flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p
|
||||
id="nx-cc-banner-title"
|
||||
class="text-sm font-semibold tracking-tight text-nx-fg"
|
||||
>
|
||||
Utilizziamo cookie e memorizzazione locale
|
||||
</p>
|
||||
<p
|
||||
id="nx-cc-banner-desc"
|
||||
class="mt-1 text-xs leading-relaxed text-nx-muted sm:text-sm"
|
||||
>
|
||||
Seleziona le categorie che preferisci. I necessari restano attivi per
|
||||
registrare la tua scelta. Dettagli nella{' '}
|
||||
<a class="text-sky-400/90 underline-offset-2 hover:text-sky-300 hover:underline" href="/cookies">informativa cookie</a>.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-shrink-0 flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-end"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-nx-cc-reject
|
||||
class="order-3 h-10 rounded-lg border border-nx-border px-4 text-sm font-medium text-nx-muted hover:border-sky-500/30 hover:text-nx-fg sm:order-1"
|
||||
>
|
||||
Rifiuta non essenziali
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-nx-cc-customize
|
||||
class="order-2 h-10 rounded-lg border border-nx-border px-4 text-sm font-medium text-nx-fg hover:border-sky-500/40 sm:order-2"
|
||||
>
|
||||
Personalizza
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-nx-cc-accept
|
||||
class="order-1 inline-flex h-10 items-center justify-center rounded-lg bg-gradient-to-r from-sky-500 to-violet-500 px-4 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 hover:brightness-110 sm:order-3"
|
||||
>
|
||||
Accetta tutto
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Overlay custom (no <dialog>): in dev Cloudflare/Vite il top layer nativo
|
||||
può dare focus e clic incoerenti; qui usiamo hidden + flex come il banner.
|
||||
-->
|
||||
<div
|
||||
id="nx-cc-dialog"
|
||||
class="fixed inset-0 z-[201] flex items-center justify-center bg-black/70 p-4 sm:p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="nx-cc-dialog-title"
|
||||
aria-hidden="true"
|
||||
hidden
|
||||
>
|
||||
<div
|
||||
class="flex max-h-[min(90vh,32rem)] w-full max-w-md flex-col overflow-hidden rounded-xl border border-nx-border bg-nx-elevated text-nx-fg shadow-2xl"
|
||||
data-nx-cc-panel
|
||||
>
|
||||
<div class="border-b border-nx-border px-5 py-4">
|
||||
<h2 id="nx-cc-dialog-title" class="text-base font-semibold tracking-tight">
|
||||
Preferenze cookie
|
||||
</h2>
|
||||
<p class="mt-1 text-xs leading-relaxed text-nx-muted">
|
||||
Versione informativa v{cookieConsentMeta.policyVersion}. Puoi modificare
|
||||
le scelte in qualsiasi momento dal footer.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="max-h-[min(52vh,22rem)] space-y-4 overflow-y-auto overscroll-contain px-5 py-4"
|
||||
>
|
||||
{
|
||||
cookieCategories.map((cat) => (
|
||||
<div class="rounded-lg border border-nx-border/80 bg-nx-bg/40 px-3 py-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-nx-fg">{cat.label}</p>
|
||||
<p class="mt-1 text-xs leading-relaxed text-nx-muted">
|
||||
{cat.description}
|
||||
</p>
|
||||
</div>
|
||||
{cat.required ? (
|
||||
<span class="shrink-0 rounded-md bg-nx-border/40 px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-nx-muted">
|
||||
Sempre attivi
|
||||
</span>
|
||||
) : (
|
||||
<label
|
||||
class="relative inline-flex h-7 w-12 shrink-0 cursor-pointer items-center rounded-full border border-nx-border bg-nx-surface transition has-[:checked]:border-sky-500/40 has-[:checked]:bg-sky-500/10"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-nx-cc-cat={cat.id}
|
||||
class="peer sr-only"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute left-0.5 top-1/2 h-5 w-5 -translate-y-1/2 rounded-full bg-nx-muted shadow peer-checked:left-[calc(100%-1.375rem)] peer-checked:bg-sky-400"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-shrink-0 flex-col-reverse gap-2 border-t border-nx-border bg-nx-bg/30 px-5 py-4 sm:flex-row sm:justify-end"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-nx-cc-cancel
|
||||
class="h-10 rounded-lg border border-nx-border px-4 text-sm font-medium text-nx-muted hover:text-nx-fg"
|
||||
>
|
||||
Chiudi
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-nx-cc-save
|
||||
class="h-10 rounded-lg bg-nx-fg px-4 text-sm font-semibold text-nx-bg hover:bg-white"
|
||||
>
|
||||
Salva preferenze
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import '../scripts/nx-cookie-consent.ts';
|
||||
</script>
|
||||
82
src/components/DataflowSpine.astro
Normal file
82
src/components/DataflowSpine.astro
Normal file
@ -0,0 +1,82 @@
|
||||
---
|
||||
/**
|
||||
* Continuazione dei flussi dati dell'hero: dalla destra verso sinistra,
|
||||
* inclinazione verso il basso, fino alla fascia del titolo "Prodotti".
|
||||
* Dietro a testo e card (main ha stacking; questo blocco è z-0).
|
||||
*/
|
||||
---
|
||||
|
||||
<div class="nx-dataflow-spine" aria-hidden="true">
|
||||
<svg
|
||||
class="nx-dataflow-spine-svg"
|
||||
viewBox="0 0 1480 2800"
|
||||
preserveAspectRatio="none"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<filter
|
||||
id="nx-spine-glow"
|
||||
x="-30%"
|
||||
y="-30%"
|
||||
width="160%"
|
||||
height="160%"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="2.2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<linearGradient id="nx-spine-stroke" x1="100%" y1="0%" x2="0%" y2="60%">
|
||||
<stop offset="0%" stop-color="#a5f3fc" stop-opacity="0.75" />
|
||||
<stop offset="40%" stop-color="#38bdf8" stop-opacity="0.9" />
|
||||
<stop offset="100%" stop-color="#22d3ee" stop-opacity="0.25" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g filter="url(#nx-spine-glow)" opacity="0.72">
|
||||
<!-- Entrata da dx (fuori canvas) → curva verso sx con pendenza verso il basso; fascia ~y 980–1280 = titolo Prodotti su viewport tipiche -->
|
||||
<path
|
||||
class="nx-flow-line nx-flow-line--a"
|
||||
vector-effect="non-scaling-stroke"
|
||||
d="M 1460 80 C 1320 320 1240 520 1080 720 C 920 920 680 1020 400 1080 C 260 1100 140 1140 60 1180"
|
||||
stroke="url(#nx-spine-stroke)"
|
||||
stroke-width="2.1"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="8 22"
|
||||
/>
|
||||
<path
|
||||
class="nx-flow-line nx-flow-line--b"
|
||||
vector-effect="non-scaling-stroke"
|
||||
d="M 1420 200 C 1280 440 1180 660 1020 860 C 860 1060 600 1120 340 1180 C 200 1210 100 1240 40 1280"
|
||||
stroke="url(#nx-spine-stroke)"
|
||||
stroke-width="1.55"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="6 18"
|
||||
opacity="0.78"
|
||||
/>
|
||||
<path
|
||||
class="nx-flow-line nx-flow-line--c"
|
||||
vector-effect="non-scaling-stroke"
|
||||
d="M 1380 40 C 1240 260 1140 480 960 680 C 800 880 520 960 280 1020 C 160 1040 60 1080 20 1120"
|
||||
stroke="#67e8f9"
|
||||
stroke-width="1.15"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="4 16"
|
||||
opacity="0.45"
|
||||
/>
|
||||
<path
|
||||
class="nx-flow-line nx-flow-line--d"
|
||||
vector-effect="non-scaling-stroke"
|
||||
d="M 1440 280 C 1300 520 1200 740 1040 940 C 880 1140 640 1200 380 1260 C 240 1290 120 1320 80 1360"
|
||||
stroke="#22d3ee"
|
||||
stroke-width="1.35"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="5 24"
|
||||
opacity="0.52"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
84
src/components/HeroDataflow.astro
Normal file
84
src/components/HeroDataflow.astro
Normal file
@ -0,0 +1,84 @@
|
||||
---
|
||||
/**
|
||||
* Decorazione hero: flusso dati stile “big data” (ciano su trasparente),
|
||||
* percorso verticale verso il basso-destra — ispirazione visiva tipo stock analytics.
|
||||
*/
|
||||
---
|
||||
|
||||
<svg
|
||||
class="nx-hero-dataflow-svg h-full w-full"
|
||||
viewBox="0 0 520 960"
|
||||
preserveAspectRatio="xMaxYMid slice"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<filter
|
||||
id="nx-flow-glow"
|
||||
x="-30%"
|
||||
y="-30%"
|
||||
width="160%"
|
||||
height="160%"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="2.5" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<linearGradient id="nx-flow-stroke" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#22d3ee" stop-opacity="0.2" />
|
||||
<stop offset="35%" stop-color="#38bdf8" stop-opacity="0.95" />
|
||||
<stop offset="100%" stop-color="#a5f3fc" stop-opacity="0.85" />
|
||||
</linearGradient>
|
||||
<linearGradient id="nx-flow-reflect" x1="0.5" y1="0" x2="0.5" y2="1">
|
||||
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.14" />
|
||||
<stop offset="55%" stop-color="#0ea5e9" stop-opacity="0.04" />
|
||||
<stop offset="100%" stop-color="#030712" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Riflesso leggero in basso (come superficie lucida) -->
|
||||
<ellipse cx="360" cy="915" rx="220" ry="52" fill="url(#nx-flow-reflect)" />
|
||||
|
||||
<!-- Flussi: dall’alto della sezione hero verso basso-destra (escono dal bordo destro) -->
|
||||
<g filter="url(#nx-flow-glow)" opacity="0.9">
|
||||
<path
|
||||
class="nx-flow-line nx-flow-line--a"
|
||||
d="M 228 0 C 232 160 140 280 268 420 C 320 500 200 600 352 720 C 400 780 388 860 508 960"
|
||||
stroke="url(#nx-flow-stroke)"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="8 22"
|
||||
/>
|
||||
<path
|
||||
class="nx-flow-line nx-flow-line--b"
|
||||
d="M 268 0 C 275 200 185 320 305 460 C 350 540 245 640 395 760 C 438 830 428 900 520 960"
|
||||
stroke="url(#nx-flow-stroke)"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="6 18"
|
||||
opacity="0.78"
|
||||
/>
|
||||
<path
|
||||
class="nx-flow-line nx-flow-line--c"
|
||||
d="M 188 40 C 195 220 95 340 238 500 C 290 590 165 700 328 820 C 368 880 352 928 472 960"
|
||||
stroke="#67e8f9"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="4 16"
|
||||
opacity="0.48"
|
||||
/>
|
||||
<path
|
||||
class="nx-flow-line nx-flow-line--d"
|
||||
d="M 308 20 C 318 240 220 360 338 520 C 385 600 285 710 418 840 C 455 895 442 938 518 960"
|
||||
stroke="#22d3ee"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="5 24"
|
||||
opacity="0.58"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
217
src/components/SiteFooter.astro
Normal file
217
src/components/SiteFooter.astro
Normal file
@ -0,0 +1,217 @@
|
||||
---
|
||||
import { footer } from '../data/home';
|
||||
|
||||
const hasNewsletterAction =
|
||||
typeof footer.newsletter.actionUrl === 'string' &&
|
||||
footer.newsletter.actionUrl.length > 0;
|
||||
|
||||
const nl = footer.newsletter;
|
||||
const nextUrl =
|
||||
typeof nl.successRedirect === 'string' && nl.successRedirect.length > 0
|
||||
? nl.successRedirect
|
||||
: '';
|
||||
const notifySubject =
|
||||
typeof nl.notifySubject === 'string' && nl.notifySubject.length > 0
|
||||
? nl.notifySubject
|
||||
: '';
|
||||
|
||||
const linkClass =
|
||||
'text-sm text-sky-400/90 transition-colors hover:text-sky-300';
|
||||
---
|
||||
|
||||
<footer class="relative z-[1] border-t border-nx-border px-4 py-12 sm:px-6">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<div class="border-b border-nx-border pb-10">
|
||||
<a
|
||||
href="/"
|
||||
class="inline-block text-base font-semibold tracking-tight text-nx-fg hover:text-white sm:text-lg"
|
||||
>{footer.brandName}</a
|
||||
>
|
||||
<p class="mt-2 max-w-xl text-sm leading-relaxed text-nx-muted">
|
||||
{footer.tagline}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quattro colonne omogenee (solo link / elenchi); preferenze cookie sotto, staccate -->
|
||||
<div class="border-b border-nx-border py-10">
|
||||
<div
|
||||
class="grid grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-4 lg:items-start lg:gap-x-10 xl:gap-x-12"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p
|
||||
class="mb-4 text-xs font-semibold uppercase tracking-wider text-nx-fg"
|
||||
>
|
||||
{footer.columnTitles.sections}
|
||||
</p>
|
||||
<ul class="space-y-2.5">
|
||||
{footer.homeNav.map((link) => (
|
||||
<li>
|
||||
<a class={linkClass} href={link.href}>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<p
|
||||
class="mb-4 text-xs font-semibold uppercase tracking-wider text-nx-fg"
|
||||
>
|
||||
{footer.columnTitles.legal}
|
||||
</p>
|
||||
<ul class="space-y-2.5">
|
||||
{footer.legalColumnNav.map((link) => (
|
||||
<li>
|
||||
<a class={linkClass} href={link.href}>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<p
|
||||
class="mb-4 text-xs font-semibold uppercase tracking-wider text-nx-fg"
|
||||
>
|
||||
{footer.columnTitles.social}
|
||||
</p>
|
||||
<ul class="space-y-2.5 text-sm">
|
||||
{footer.socialLinks.map((item) => (
|
||||
<li>
|
||||
{item.href ? (
|
||||
<a
|
||||
class={linkClass}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
) : (
|
||||
<span
|
||||
class="cursor-not-allowed text-nx-muted opacity-50"
|
||||
title="URL da configurare in footer.ts"
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<p
|
||||
class="mb-4 text-xs font-semibold uppercase tracking-wider text-nx-fg"
|
||||
>
|
||||
{footer.columnTitles.company}
|
||||
</p>
|
||||
<ul class="space-y-2.5">
|
||||
{footer.companyNav.map((link) => (
|
||||
<li>
|
||||
{link.href ? (
|
||||
<a class={linkClass} href={link.href}>
|
||||
{link.label}
|
||||
</a>
|
||||
) : (
|
||||
<span class="text-sm text-nx-muted" title="Collegamento in arrivo">
|
||||
{link.label}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-10 flex flex-col gap-3 border-t border-nx-border/70 pt-8 sm:flex-row sm:items-center sm:gap-6"
|
||||
data-nx-footer-print-collapsible
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-10 shrink-0 items-center justify-center self-start rounded-lg border border-nx-border bg-nx-bg/50 px-4 text-sm font-medium text-sky-400/90 transition-colors hover:border-sky-500/35 hover:bg-sky-500/5 hover:text-sky-300 sm:self-auto"
|
||||
data-nx-cc-open
|
||||
>
|
||||
{footer.cookiePreferences.label}
|
||||
</button>
|
||||
<p class="max-w-xl text-xs leading-relaxed text-nx-muted sm:flex-1">
|
||||
{footer.cookiePreferences.hint}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Una sola area newsletter, allineata a sinistra come la colonna Sezioni -->
|
||||
<div class="pt-10 lg:max-w-md" data-nx-footer-print-collapsible>
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-nx-fg">
|
||||
{footer.newsletter.title}
|
||||
</p>
|
||||
<p class="mt-1.5 text-xs leading-relaxed text-nx-muted">
|
||||
{footer.newsletter.description}
|
||||
</p>
|
||||
{
|
||||
hasNewsletterAction ? (
|
||||
<form
|
||||
class="relative mt-3 flex flex-col gap-2 sm:flex-row sm:items-stretch"
|
||||
action={nl.actionUrl}
|
||||
method="post"
|
||||
accept-charset="UTF-8"
|
||||
>
|
||||
{nextUrl ? <input type="hidden" name="_next" value={nextUrl} /> : null}
|
||||
{notifySubject ? (
|
||||
<input type="hidden" name="_subject" value={notifySubject} />
|
||||
) : null}
|
||||
<input
|
||||
type="text"
|
||||
name="_gotcha"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute -left-[9999px] h-0 w-0 opacity-0"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
name={nl.fieldName}
|
||||
required
|
||||
autocomplete="email"
|
||||
placeholder={nl.placeholder}
|
||||
class="min-h-10 w-full rounded-lg border border-nx-border bg-nx-bg/80 px-3 text-sm text-nx-fg placeholder:text-nx-muted/70 outline-none ring-sky-500/30 focus:border-sky-500/40 focus:ring-2"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex h-10 shrink-0 items-center justify-center rounded-lg bg-nx-fg px-4 text-sm font-semibold text-nx-bg hover:bg-white sm:px-5"
|
||||
>
|
||||
{nl.buttonLabel}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div class="mt-3 space-y-2">
|
||||
<input
|
||||
type="email"
|
||||
disabled
|
||||
placeholder={nl.placeholder}
|
||||
class="min-h-10 w-full cursor-not-allowed rounded-lg border border-nx-border bg-nx-bg/40 px-3 text-sm text-nx-muted opacity-60"
|
||||
aria-describedby="nx-newsletter-hint"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
class="inline-flex h-10 w-full cursor-not-allowed items-center justify-center rounded-lg border border-nx-border bg-nx-elevated/50 text-sm font-medium text-nx-muted opacity-70 sm:w-auto sm:px-5"
|
||||
>
|
||||
{nl.buttonLabel}
|
||||
</button>
|
||||
<p id="nx-newsletter-hint" class="text-xs leading-relaxed text-nx-muted">
|
||||
Iscrizione in arrivo: configura l’endpoint nel progetto per attivare il modulo.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="pt-8 text-xs leading-relaxed text-nx-muted sm:text-sm">
|
||||
© {new Date().getFullYear()} NexStudio. {footer.legalLine}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
271
src/components/WireframeGlobeSvg.astro
Normal file
271
src/components/WireframeGlobeSvg.astro
Normal file
@ -0,0 +1,271 @@
|
||||
---
|
||||
/**
|
||||
* Sfera wireframe geodetica (icosfera) generata a build, solo stroke, sfondo trasparente.
|
||||
* Profondità: opacità da z proiettato come nelle linee canvas delle particelle.
|
||||
*/
|
||||
type Vec3 = [number, number, number];
|
||||
|
||||
function norm(v: Vec3): Vec3 {
|
||||
const L = Math.hypot(v[0], v[1], v[2]);
|
||||
if (L < 1e-8) return [0, 0, 1];
|
||||
return [v[0] / L, v[1] / L, v[2] / L];
|
||||
}
|
||||
|
||||
function addV(vs: Vec3[], v: Vec3): number {
|
||||
vs.push(norm(v));
|
||||
return vs.length - 1;
|
||||
}
|
||||
|
||||
const T = (1 + Math.sqrt(5)) / 2;
|
||||
const verts: Vec3[] = [
|
||||
[-1, T, 0],
|
||||
[1, T, 0],
|
||||
[-1, -T, 0],
|
||||
[1, -T, 0],
|
||||
[0, -1, T],
|
||||
[0, 1, T],
|
||||
[0, -1, -T],
|
||||
[0, 1, -T],
|
||||
[T, 0, -1],
|
||||
[T, 0, 1],
|
||||
[-T, 0, -1],
|
||||
[-T, 0, 1],
|
||||
].map(norm);
|
||||
|
||||
/** Ordine vertici come three.js IcosahedronGeometry */
|
||||
let faces: [number, number, number][] = [
|
||||
[0, 11, 5],
|
||||
[0, 5, 1],
|
||||
[0, 1, 7],
|
||||
[0, 7, 10],
|
||||
[0, 10, 11],
|
||||
[1, 5, 9],
|
||||
[5, 11, 4],
|
||||
[11, 10, 2],
|
||||
[10, 7, 6],
|
||||
[7, 1, 8],
|
||||
[3, 9, 4],
|
||||
[3, 4, 2],
|
||||
[3, 2, 6],
|
||||
[3, 6, 8],
|
||||
[3, 8, 9],
|
||||
[4, 9, 5],
|
||||
[2, 4, 11],
|
||||
[6, 2, 10],
|
||||
[8, 6, 7],
|
||||
[9, 8, 1],
|
||||
];
|
||||
|
||||
const midCache = new Map<string, number>();
|
||||
function mid(a: number, b: number): number {
|
||||
const z = a < b ? `${a}_${b}` : `${b}_${a}`;
|
||||
if (midCache.has(z)) return midCache.get(z)!;
|
||||
const id = addV(verts, [
|
||||
verts[a][0] + verts[b][0],
|
||||
verts[a][1] + verts[b][1],
|
||||
verts[a][2] + verts[b][2],
|
||||
]);
|
||||
midCache.set(z, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function subdivide(): void {
|
||||
midCache.clear();
|
||||
const next: [number, number, number][] = [];
|
||||
for (const [i, j, k] of faces) {
|
||||
const a = mid(i, j);
|
||||
const b = mid(j, k);
|
||||
const c = mid(k, i);
|
||||
next.push([i, a, c], [j, b, a], [k, c, b], [a, b, c]);
|
||||
}
|
||||
faces = next;
|
||||
}
|
||||
|
||||
/** 2 suddivisioni → mesh fine tipo riferimento (~162 vertici) */
|
||||
subdivide();
|
||||
subdivide();
|
||||
|
||||
const edges = new Set<string>();
|
||||
for (const [i, j, k] of faces) {
|
||||
const pushE = (a: number, b: number) => {
|
||||
const e = a < b ? `${a}_${b}` : `${b}_${a}`;
|
||||
edges.add(e);
|
||||
};
|
||||
pushE(i, j);
|
||||
pushE(j, k);
|
||||
pushE(k, i);
|
||||
}
|
||||
|
||||
/** Rotazione: sfera “in basso a destra”, bulge verso osservatore */
|
||||
function rotY(v: Vec3, ang: number): Vec3 {
|
||||
const c = Math.cos(ang);
|
||||
const s = Math.sin(ang);
|
||||
return [c * v[0] + s * v[2], v[1], -s * v[0] + c * v[2]];
|
||||
}
|
||||
|
||||
function rotX(v: Vec3, ang: number): Vec3 {
|
||||
const c = Math.cos(ang);
|
||||
const s = Math.sin(ang);
|
||||
return [v[0], c * v[1] - s * v[2], s * v[1] + c * v[2]];
|
||||
}
|
||||
|
||||
const ry = -0.62;
|
||||
const rx = -0.35;
|
||||
const rz = 0.12;
|
||||
|
||||
function rotZ(v: Vec3, ang: number): Vec3 {
|
||||
const c = Math.cos(ang);
|
||||
const s = Math.sin(ang);
|
||||
return [c * v[0] - s * v[1], s * v[0] + c * v[1], v[2]];
|
||||
}
|
||||
|
||||
function transform(v: Vec3): Vec3 {
|
||||
let p: Vec3 = [...v] as Vec3;
|
||||
p = rotY(p, ry);
|
||||
p = rotX(p, rx);
|
||||
p = rotZ(p, rz);
|
||||
return p;
|
||||
}
|
||||
|
||||
const D = 2.65;
|
||||
const SCALE = 110;
|
||||
|
||||
type P2 = { x: number; y: number; z: number };
|
||||
|
||||
const projected: P2[] = verts.map((v) => {
|
||||
const p = transform(v);
|
||||
const d = D - p[2];
|
||||
const s = d > 0.15 ? D / d : 0;
|
||||
return {
|
||||
x: p[0] * s * SCALE,
|
||||
y: -p[1] * s * SCALE,
|
||||
z: p[2],
|
||||
};
|
||||
});
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const p of projected) {
|
||||
minX = Math.min(minX, p.x);
|
||||
maxX = Math.max(maxX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
maxY = Math.max(maxY, p.y);
|
||||
}
|
||||
|
||||
const pad = 4;
|
||||
const vbW = maxX - minX + pad * 2;
|
||||
const vbH = maxY - minY + pad * 2;
|
||||
|
||||
function toSvg(p: P2): [number, number] {
|
||||
return [p.x - minX + pad, p.y - minY + pad];
|
||||
}
|
||||
|
||||
const zMin = Math.min(...projected.map((p) => p.z));
|
||||
const zMax = Math.max(...projected.map((p) => p.z));
|
||||
function depthT(z: number): number {
|
||||
return zMax > zMin ? (z - zMin) / (zMax - zMin) : 0.5;
|
||||
}
|
||||
|
||||
/** Raggruppa segmenti per profondità per rendere la tridimensionalità più leggibile */
|
||||
const buckets: Record<string, string[]> = { b0: [], b1: [], b2: [], b3: [] };
|
||||
for (const e of edges) {
|
||||
const [a, b] = e.split('_').map(Number);
|
||||
const pa = projected[a];
|
||||
const pb = projected[b];
|
||||
const [xa, ya] = toSvg(pa);
|
||||
const [xb, yb] = toSvg(pb);
|
||||
const t = depthT((pa.z + pb.z) * 0.5);
|
||||
const key = t < 0.28 ? 'b0' : t < 0.5 ? 'b1' : t < 0.74 ? 'b2' : 'b3';
|
||||
buckets[key].push(`M${xa} ${ya}L${xb} ${yb}`);
|
||||
}
|
||||
|
||||
const pathD0 = buckets.b0.join('');
|
||||
const pathD1 = buckets.b1.join('');
|
||||
const pathD2 = buckets.b2.join('');
|
||||
const pathD3 = buckets.b3.join('');
|
||||
|
||||
const nodeR = Math.max(0.34, Math.min(vbW, vbH) * 0.0038);
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
const { class: className } = Astro.props as Props;
|
||||
---
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox={`0 0 ${vbW} ${vbH}`}
|
||||
class={className}
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<filter id="nx-globe-front-glow" x="-60%" y="-60%" width="220%" height="220%">
|
||||
<feGaussianBlur stdDeviation="1.4" result="blur"></feGaussianBlur>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"></feMergeNode>
|
||||
<feMergeNode in="SourceGraphic"></feMergeNode>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<g stroke-linecap="round">
|
||||
{pathD0 && (
|
||||
<path
|
||||
d={pathD0}
|
||||
stroke="rgb(56 189 248)"
|
||||
stroke-opacity="0.115"
|
||||
stroke-width="0.86"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
{pathD1 && (
|
||||
<path
|
||||
d={pathD1}
|
||||
stroke="rgb(56 189 248)"
|
||||
stroke-opacity="0.19"
|
||||
stroke-width="1.05"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
{pathD2 && (
|
||||
<path
|
||||
d={pathD2}
|
||||
stroke="rgb(56 189 248)"
|
||||
stroke-opacity="0.24"
|
||||
stroke-width="1.22"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
{pathD3 && (
|
||||
<path
|
||||
d={pathD3}
|
||||
stroke="rgb(125 211 252)"
|
||||
stroke-opacity="0.41"
|
||||
stroke-width="1.52"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
<g fill="rgb(147 197 253)">
|
||||
{verts.map((_, idx) => {
|
||||
const p = projected[idx];
|
||||
const [cx, cy] = toSvg(p);
|
||||
const t = depthT(p.z);
|
||||
const op = 0.14 + t * 0.62;
|
||||
const radius = nodeR * (0.86 + t * 1.3);
|
||||
const isFront = t > 0.75;
|
||||
return (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
fill-opacity={op}
|
||||
fill={isFront ? 'rgb(224 242 254)' : 'rgb(147 197 253)'}
|
||||
r={radius}
|
||||
filter={isFront ? 'url(#nx-globe-front-glow)' : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
Reference in New Issue
Block a user