diff --git a/frontend/src/app/features/materials/materials-page.component.html b/frontend/src/app/features/materials/materials-page.component.html new file mode 100644 index 0000000..0af8b3c --- /dev/null +++ b/frontend/src/app/features/materials/materials-page.component.html @@ -0,0 +1,397 @@ +
+
+
+

Guida materiali

+

Qualita e Materiali

+

+ Confronta materiali in modo interattivo con radar chart, metriche tecniche, + vantaggi, limiti e fonti verificabili. +

+

+ Seleziona fino a {{ maxCompareCount }} materiali: il grafico aggiorna i + punteggi in tempo reale. +

+
+
+ +
+
+

Selezione confronto

+
+ @for (material of materials; track trackMaterial($index, material)) { + + } +
+

+ Nota: per l asse Economicita, un valore alto significa costo al kg piu + conveniente. +

+
+
+ +
+
+
+
+

Radar chart comparativo

+

+ Punteggi normalizzati 0-100 su tutto il set materiali (min-max scaling). +

+
+ + + + @for (ring of ringPolygons(); track $index) { + + } + + + + @for (axis of axisGuides(); track axis.id) { + + + {{ radarAxes[$index].label }} + + } + + + + @for (series of radarSeries(); track series.material.id) { + + + @for (point of series.values; track point.axis.id) { + + } + } + + + +
+ @for (series of radarSeries(); track series.material.id) { + + } +
+
+ +
+

Spiegazione completa del radar

+

+ Ogni asse mostra una proprieta tecnica. Il valore 100 rappresenta la + miglior performance relativa nel dataset attuale; 0 la meno favorevole. +

+
    + @for (axis of radarAxes; track axis.id) { +
  • + {{ axis.label }}: + {{ axis.description }} +
  • + } +
+

+ La normalizzazione e calcolata su tutti i materiali mostrati in pagina. + Per leggibilita il radar usa un raggio minimo visivo: i valori minimi + restano i meno favorevoli, ma non collassano tutti nello stesso punto. +

+
+
+
+ +
+
+

Tabella tecnica di confronto

+
+ + + + + @for (material of selectedMaterials(); track trackMaterial($index, material)) { + + } + + + + @for (row of comparisonRows(); track row.label) { + + + @for (value of row.values; track $index) { + + } + + } + +
Parametro{{ material.name }}
{{ row.label }}{{ value }}
+
+
+
+ +
+
+

Layer, ugello e infill: esempi pratici

+

+ Questa sezione non e un calcolatore interattivo: spiega visivamente cosa + cambia su oggetti reali e come leggere i risultati del vostro calcolatore. +

+ +
+ @for (guide of qualityVisualCards(); track trackVisualGuide($index, guide)) { +
+

{{ guide.category }}

+

{{ guide.title }}

+ +
+ @if (guide.image; as image) { + + @if (image.source.avifUrl) { + + } + @if (image.source.webpUrl) { + + } + + + } @else { +
+ + Nessuna immagine assegnata. + Carica asset backend con usageType + MATERIALS_PAGE e usageKey + {{ guide.usageKey }}. + +
+ } +
+ +

Oggetto esempio: {{ guide.objectExample }}

+

Meglio per: {{ guide.bestFor }}

+

Limite: {{ guide.tradeoff }}

+

+ Lettura nel calcolatore: {{ guide.calculatorRead }} +

+
+ } +
+ +
+

Come leggere il nostro calcolatore

+

+ Il calcolatore non sostituisce i profili slicer: serve a spiegare il + compromesso tra estetica, robustezza e tempi in modo coerente. +

+
+ + + + + + + + + + + @for (rule of calculatorRules; track rule.metric) { + + + + + + + } + +
MetricaCosa significaValore altoValore basso
{{ rule.metric }}{{ rule.whatItMeans }}{{ rule.whenHigh }}{{ rule.whenLow }}
+
+
+ +
+
+

Regole rapide per l utente

+
    +
  • + Layer basso e ugello piccolo migliorano i dettagli, ma aumentano i + tempi. +
  • +
  • + Infill e perimetri alti migliorano resistenza, ma aumentano tempo e + materiale. +
  • +
  • + Per pezzi estetici usa profili fini; per pezzi funzionali scegli setup + bilanciati o robusti. +
  • +
+
+
+ +
+ @for (guide of qualityGuides; track trackGuide($index, guide)) { +
+

{{ guide.title }}

+

Range consigliato: {{ guide.recommendation }}

+

{{ guide.explanation }}

+

{{ guide.practicalEffect }}

+
+ } +
+
+
+ +
+
+

Schede materiali: spiegazioni, pro/contro, fonti

+
+ @for (card of selectedCards(); track card.material.id) { +
+
+

{{ card.material.name }}

+

{{ card.material.summary }}

+
+ +
+ @if (card.image; as image) { + + @if (image.source.avifUrl) { + + } + @if (image.source.webpUrl) { + + } + + + } @else { +
+ + Nessuna immagine assegnata. + Carica asset backend con usageType + MATERIALS_PAGE e usageKey + material-{{ card.material.id }}. + +
+ } +
+ +
+
+

Vantaggi

+
    + @for (item of card.material.pros; track $index) { +
  • {{ item }}
  • + } +
+
+
+

Limiti

+
    + @for (item of card.material.cons; track $index) { +
  • {{ item }}
  • + } +
+
+
+

Ideale per

+
    + @for (item of card.material.idealFor; track $index) { +
  • {{ item }}
  • + } +
+
+
+ +
+

Fonti citate

+
    + @for (source of card.material.sources; track trackSource($index, source)) { +
  • + + {{ source.label }} + + {{ source.kind }} +
  • + } +
+
+
+ } +
+
+
+ +
+
+

Indice completo fonti

+

+ Tutti i link usati per metriche e descrizioni sono riportati qui in forma + centralizzata. +

+
    + @for (source of allSources(); track trackSource($index, source)) { +
  • + + {{ source.label }} + + {{ source.kind }} +
  • + } +
+
+
+
diff --git a/frontend/src/app/features/materials/materials-page.component.scss b/frontend/src/app/features/materials/materials-page.component.scss new file mode 100644 index 0000000..8e70825 --- /dev/null +++ b/frontend/src/app/features/materials/materials-page.component.scss @@ -0,0 +1,546 @@ +.materials-page { + --materials-bg: #ffffff; + --materials-accent: #c23b22; + --materials-muted: #5f6771; + --materials-card: #ffffff; + background: var(--materials-bg); + color: var(--color-text-main); +} + +.hero { + padding: 5rem 0 2.25rem; + position: relative; + overflow: hidden; +} + +.hero::before { + content: ''; + position: absolute; + inset: -40% -15% auto auto; + width: 420px; + height: 420px; + background: radial-gradient(circle, rgba(194, 59, 34, 0.08), transparent 70%); + pointer-events: none; +} + +.hero-inner { + position: relative; + z-index: 1; +} + +.hero h1 { + margin: 0.4rem 0 1rem; + font-size: clamp(2rem, 4vw, 3rem); + line-height: 1.05; +} + +.hero-lead { + margin: 0; + max-width: 68ch; + font-size: 1.05rem; + color: var(--color-text-main); +} + +.hero-note { + margin: 0.9rem 0 0; + color: var(--materials-muted); +} + +.selector-section, +.chart-section, +.table-section, +.quality-section, +.materials-section, +.global-sources { + padding: 1.8rem 0; +} + +.selector-grid { + margin-top: 1rem; + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +.selector-chip { + border: 1px solid var(--color-border); + background: #fff; + color: var(--color-text-main); + border-radius: 999px; + padding: 0.5rem 0.9rem; + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease, + background-color 0.2s ease; +} + +.selector-chip:hover:enabled { + transform: translateY(-1px); + border-color: var(--materials-accent); + box-shadow: 0 4px 12px rgb(16 24 32 / 0.12); +} + +.selector-chip:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.selector-chip.is-selected { + border-color: var(--materials-accent); + background: #fff3ee; +} + +.selector-dot { + width: 0.7rem; + height: 0.7rem; + border-radius: 50%; + border: 1px solid rgb(0 0 0 / 0.15); + display: inline-block; +} + +.selector-help { + margin-top: 0.8rem; + color: var(--materials-muted); +} + +.chart-layout { + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + align-items: start; +} + +.chart-card, +.explain-card, +.material-card, +.table-wrap, +.quality-card, +.guide-card { + background: var(--materials-card); + border: 1px solid var(--color-border); + border-radius: 1rem; + box-shadow: 0 10px 28px rgb(16 24 32 / 0.05); +} + +.chart-card { + padding: 1rem; +} + +.chart-header h2 { + margin: 0; +} + +.chart-header p { + margin: 0.5rem 0 0; + color: var(--materials-muted); +} + +.radar-chart { + width: 100%; + max-width: 520px; + margin: 0 auto; + display: block; +} + +.chart-rings polygon { + fill: none; + stroke: #d7d9de; + stroke-width: 1; +} + +.chart-axes line { + stroke: #c3c8cf; + stroke-width: 1; +} + +.chart-axes text { + font-size: 0.75rem; + fill: #4f5a66; + font-weight: 600; +} + +.series-shape { + stroke-width: 2.2; + transition: filter 0.2s ease; +} + +.series-shape.is-hovered { + filter: drop-shadow(0 3px 8px rgb(16 24 32 / 0.26)); +} + +.series-node { + stroke: #ffffff; + stroke-width: 1.2; +} + +.chart-legend { + margin-top: 0.75rem; + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +.legend-item { + border: 1px solid var(--color-border); + border-radius: 999px; + padding: 0.35rem 0.7rem; + background: #fff; + display: inline-flex; + gap: 0.45rem; + align-items: center; + font-weight: 600; + cursor: default; +} + +.legend-dot { + width: 0.65rem; + height: 0.65rem; + border-radius: 50%; + display: inline-block; +} + +.explain-card { + padding: 1rem; +} + +.explain-card h3 { + margin: 0; +} + +.explain-card p { + margin: 0.75rem 0; + color: var(--materials-muted); +} + +.explain-card ul { + margin: 0; + padding-left: 1.1rem; + display: grid; + gap: 0.45rem; +} + +.table-wrap { + overflow-x: auto; +} + +.table-wrap table { + width: 100%; + border-collapse: collapse; + min-width: 760px; +} + +.table-wrap th, +.table-wrap td { + padding: 0.6rem 0.75rem; + border-bottom: 1px solid var(--color-border); + text-align: left; +} + +.table-wrap thead th { + background: #f8fafd; + font-size: 0.84rem; + letter-spacing: 0.01em; +} + +.table-wrap tbody tr:hover { + background: #f8fbff; +} + +.quality-intro { + margin: 0.4rem 0 0; + color: var(--materials-muted); + max-width: 72ch; +} + +.visual-guide-grid { + margin-top: 1rem; + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); +} + +.visual-guide-card { + background: #fff; + border: 1px solid var(--color-border); + border-radius: 1rem; + box-shadow: 0 10px 28px rgb(16 24 32 / 0.05); + padding: 0.85rem; + display: grid; + gap: 0.55rem; +} + +.visual-guide-category { + margin: 0; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #2563b8; +} + +.visual-guide-card h3 { + margin: 0; + font-size: 1.02rem; +} + +.visual-guide-media { + border: 1px solid var(--color-border); + border-radius: 0.75rem; + overflow: hidden; + background: #f7f8fb; + min-height: 170px; +} + +.visual-guide-media img { + width: 100%; + height: 185px; + object-fit: cover; + display: block; +} + +.visual-guide-card p { + margin: 0; + color: var(--materials-muted); + font-size: 0.92rem; + line-height: 1.42; +} + +.visual-guide-calc { + margin-top: 0.2rem; + color: var(--color-text-main); +} + +.calculator-logic-card { + margin-top: 1rem; + background: #fff; + border: 1px solid var(--color-border); + border-radius: 1rem; + box-shadow: 0 10px 28px rgb(16 24 32 / 0.05); + padding: 1rem; +} + +.calculator-logic-card h3 { + margin: 0; +} + +.calculator-logic-card p { + margin: 0.6rem 0 0; + color: var(--materials-muted); +} + +.logic-table-wrap { + margin-top: 0.75rem; + overflow-x: auto; +} + +.logic-table-wrap table { + width: 100%; + border-collapse: collapse; + min-width: 720px; +} + +.logic-table-wrap th, +.logic-table-wrap td { + border-bottom: 1px solid var(--color-border); + text-align: left; + padding: 0.62rem 0.7rem; +} + +.logic-table-wrap thead th { + background: #f8fafd; + font-size: 0.84rem; + letter-spacing: 0.01em; +} + +.quality-layout { + margin-top: 1rem; + display: grid; + gap: 1rem; + grid-template-columns: 1fr; +} + +.quality-card { + padding: 1rem; +} + +.quality-card h3 { + margin: 0; +} + +.quality-card ul { + margin: 0.7rem 0 0; + padding-left: 1rem; + display: grid; + gap: 0.45rem; + color: var(--materials-muted); +} + +.guides-grid { + margin-top: 1rem; + display: grid; + gap: 0.8rem; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.guide-card { + padding: 0.9rem; +} + +.guide-card h3 { + margin: 0; + font-size: 1rem; +} + +.guide-card p { + margin: 0.55rem 0 0; + color: var(--materials-muted); +} + +.guide-effect { + color: var(--color-text-main); + font-weight: 500; +} + +.materials-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + +.material-card { + padding: 1rem; +} + +.material-card h3 { + margin: 0; + font-size: 1.25rem; +} + +.material-card header p { + margin: 0.55rem 0 0; + color: var(--materials-muted); +} + +.material-media { + margin-top: 0.85rem; + border-radius: 0.75rem; + overflow: hidden; + border: 1px solid var(--color-border); + background: #f7f8fb; + min-height: 180px; +} + +.material-media img { + width: 100%; + height: 220px; + object-fit: cover; + display: block; +} + +.media-fallback { + min-height: 180px; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + color: var(--materials-muted); + text-align: center; + font-size: 0.9rem; + line-height: 1.45; +} + +.material-columns { + margin-top: 0.9rem; + display: grid; + gap: 0.7rem; +} + +.material-columns h4, +.source-list h4 { + margin: 0; + font-size: 0.95rem; +} + +.material-columns ul, +.source-list ul, +.global-source-list { + margin: 0.45rem 0 0; + padding-left: 1rem; + display: grid; + gap: 0.35rem; +} + +.source-list { + margin-top: 0.9rem; + padding-top: 0.9rem; + border-top: 1px solid var(--color-border); +} + +.source-list li, +.global-source-list li { + display: flex; + gap: 0.5rem; + align-items: baseline; + justify-content: space-between; +} + +.source-list a, +.global-source-list a { + color: #14409b; + word-break: break-word; +} + +.source-kind { + color: var(--materials-muted); + font-size: 0.8rem; + white-space: nowrap; +} + +.global-sources p { + color: var(--materials-muted); +} + +.global-source-list { + background: #fff; + border: 1px solid var(--color-border); + border-radius: 0.9rem; + padding: 1rem 1rem 1rem 1.35rem; +} + +@media (max-width: 1024px) { + .chart-layout { + grid-template-columns: 1fr; + } + + .quality-layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .hero { + padding-top: 4.2rem; + } + + .chart-card, + .explain-card, + .material-card { + padding: 0.85rem; + } + + .table-wrap table { + min-width: 640px; + } + + .source-list li, + .global-source-list li { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/frontend/src/app/features/materials/materials-page.component.ts b/frontend/src/app/features/materials/materials-page.component.ts new file mode 100644 index 0000000..6601951 --- /dev/null +++ b/frontend/src/app/features/materials/materials-page.component.ts @@ -0,0 +1,1018 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { + PublicMediaDisplayImage, + PublicMediaService, + PublicMediaUsageCollectionMap, + buildPublicMediaUsageScopeKey, +} from '../../core/services/public-media.service'; + +interface MaterialSource { + label: string; + kind: 'Wikipedia' | 'Scheda tecnica' | 'Vendor'; + url: string; +} + +interface MaterialMetrics { + priceChfKg: number; + densityGcm3: number; + tensileMpa: number; + modulusGpa: number; + elongationPct: number; + hdtC: number; + extrusionC: string; + printability: number; + layerRangeMm: string; +} + +interface MaterialRecord { + id: string; + name: string; + summary: string; + qualityTips: readonly string[]; + metrics: MaterialMetrics; + pros: readonly string[]; + cons: readonly string[]; + idealFor: readonly string[]; + sources: readonly MaterialSource[]; +} + +type RadarAxisId = + | 'economy' + | 'printability' + | 'tensile' + | 'modulus' + | 'elongation' + | 'hdt'; + +interface RadarAxis { + id: RadarAxisId; + label: string; + description: string; + unit: string; + lowerIsBetter?: boolean; + accessor: (material: MaterialRecord) => number; +} + +interface RadarPoint { + axis: RadarAxis; + score: number; + rawValue: number; + x: number; + y: number; +} + +interface RadarSeries { + material: MaterialRecord; + color: string; + fill: string; + points: string; + values: readonly RadarPoint[]; +} + +interface AxisGuide { + id: RadarAxisId; + fromX: number; + fromY: number; + x: number; + y: number; + labelX: number; + labelY: number; + labelAnchor: 'start' | 'middle' | 'end'; +} + +interface MaterialCard { + material: MaterialRecord; + image: PublicMediaDisplayImage | null; +} + +interface ComparisonRow { + label: string; + values: readonly string[]; +} + +interface QualityGuide { + id: string; + title: string; + recommendation: string; + explanation: string; + practicalEffect: string; +} + +interface QualityVisualGuide { + id: string; + category: 'Layer' | 'Ugello' | 'Infill'; + title: string; + objectExample: string; + bestFor: string; + tradeoff: string; + calculatorRead: string; + usageKey: string; +} + +interface QualityVisualCard extends QualityVisualGuide { + image: PublicMediaDisplayImage | null; +} + +interface CalculatorRule { + metric: string; + whatItMeans: string; + whenHigh: string; + whenLow: string; +} + +const MATERIALS: readonly MaterialRecord[] = [ + { + id: 'tpu-95a-hf', + name: 'TPU 95A HF', + summary: + 'Materiale molto flessibile, utile quando serve assorbire urti e vibrazioni.', + qualityTips: [ + 'Riduci velocita e accelerazioni per stabilita estrusione.', + 'Usa layer medio-alto per ridurre artefatti su superfici morbide.', + ], + metrics: { + priceChfKg: 30, + densityGcm3: 1.22, + tensileMpa: 27, + modulusGpa: 0.01, + elongationPct: 650, + hdtC: 0, + extrusionC: '220 - 240', + printability: 70, + layerRangeMm: '0.20 - 0.32', + }, + pros: [ + 'Altissima flessibilita e resilienza agli urti.', + 'Buona adesione tra layer su geometrie morbide.', + ], + cons: [ + 'Sensibile all umidita durante stampa e conservazione.', + 'Rigidita molto bassa e resistenza termica limitata.', + ], + idealFor: ['Guarnizioni', 'Bumper', 'Grip', 'Cover antiurto'], + sources: [ + { + label: 'Wikipedia - Thermoplastic polyurethane', + kind: 'Wikipedia', + url: 'https://en.wikipedia.org/wiki/Thermoplastic_polyurethane', + }, + { + label: 'Bambu Lab - TPU 95A HF', + kind: 'Vendor', + url: 'https://eu.store.bambulab.com/it/products/tpu-95a-hf', + }, + { + label: 'Ultimaker - TPU 95A', + kind: 'Scheda tecnica', + url: 'https://ultimaker.com/materials/s-series-tpu-95a/', + }, + ], + }, + { + id: 'pla-basic', + name: 'PLA Basic', + summary: + 'Scelta semplice per prototipi rapidi con buona finitura e stabilita di stampa.', + qualityTips: [ + 'Per qualita visuale usa layer 0.12-0.16 mm.', + 'Per produttivita usa layer 0.20-0.24 mm.', + ], + metrics: { + priceChfKg: 18, + densityGcm3: 1.24, + tensileMpa: 35, + modulusGpa: 2.58, + elongationPct: 12, + hdtC: 57, + extrusionC: '190 - 230', + printability: 96, + layerRangeMm: '0.12 - 0.24', + }, + pros: ['Facile da stampare.', 'Ottima qualita superficiale su pezzi visuali.'], + cons: [ + 'Fragile sotto urto.', + 'Scarsa resistenza al calore e all UV prolungato.', + ], + idealFor: ['Mockup', 'Prototipi rapidi', 'Pezzi estetici indoor'], + sources: [ + { + label: 'Wikipedia - Polylactic acid', + kind: 'Wikipedia', + url: 'https://en.wikipedia.org/wiki/Polylactic_acid', + }, + { + label: 'Bambu Lab - PLA Basic', + kind: 'Vendor', + url: 'https://eu.store.bambulab.com/it/products/pla-basic-filament', + }, + { + label: 'Ultimaker - PLA', + kind: 'Scheda tecnica', + url: 'https://ultimaker.com/materials/pla/', + }, + ], + }, + { + id: 'pla-matte', + name: 'PLA Matte', + summary: + 'PLA con resa opaca per priorita estetica su modelli e parti espositive.', + qualityTips: [ + 'Layer 0.16-0.24 mm mantiene effetto opaco uniforme.', + 'Raffreddamento costante aiuta la texture superficiale.', + ], + metrics: { + priceChfKg: 18, + densityGcm3: 1.31, + tensileMpa: 30, + modulusGpa: 1.96, + elongationPct: 15, + hdtC: 58, + extrusionC: '190 - 230', + printability: 94, + layerRangeMm: '0.16 - 0.24', + }, + pros: ['Aspetto opaco pulito.', 'Stampa semplice come il PLA base.'], + cons: [ + 'Proprieta meccaniche limitate per uso strutturale.', + 'Sensibile al calore come altri PLA.', + ], + idealFor: ['Oggetti espositivi', 'Design prodotto', 'Mockup estetici'], + sources: [ + { + label: 'Wikipedia - Polylactic acid', + kind: 'Wikipedia', + url: 'https://en.wikipedia.org/wiki/Polylactic_acid', + }, + { + label: 'Bambu Lab - PLA Matte', + kind: 'Vendor', + url: 'https://eu.store.bambulab.com/it/products/pla-matte', + }, + ], + }, + { + id: 'pla-tough-plus', + name: 'PLA Tough+', + summary: + 'Compromesso tra facilita del PLA e maggiore tenacita per uso funzionale leggero.', + qualityTips: [ + 'Layer 0.20 mm e un buon compromesso velocita/precisione.', + 'Usa 3-4 perimetri per aumentare robustezza in urto.', + ], + metrics: { + priceChfKg: 22, + densityGcm3: 1.21, + tensileMpa: 35, + modulusGpa: 1.86, + elongationPct: 9, + hdtC: 61, + extrusionC: '220 - 250', + printability: 89, + layerRangeMm: '0.16 - 0.24', + }, + pros: [ + 'Maggiore tenacita rispetto al PLA standard.', + 'Buon compromesso per prototipi funzionali.', + ], + cons: [ + 'Resistenza termica ancora moderata.', + 'Meno rigido del PLA basic.', + ], + idealFor: [ + 'Pezzi funzionali leggeri', + 'Attrezzi non strutturali', + 'Componenti test', + ], + sources: [ + { + label: 'Wikipedia - Polylactic acid', + kind: 'Wikipedia', + url: 'https://en.wikipedia.org/wiki/Polylactic_acid', + }, + { + label: 'Bambu Lab - PLA Tough', + kind: 'Vendor', + url: 'https://eu.store.bambulab.com/it/products/pla-tough-upgrade', + }, + ], + }, + { + id: 'asa', + name: 'ASA', + summary: 'Polimero tecnico per esterno, piu stabile agli UV rispetto ad ABS.', + qualityTips: [ + 'Camera chiusa e brim riducono warping.', + 'Layer 0.20-0.28 mm per bilanciare adesione e tempi.', + ], + metrics: { + priceChfKg: 23, + densityGcm3: 1.07, + tensileMpa: 45, + modulusGpa: 2.1, + elongationPct: 10, + hdtC: 95, + extrusionC: '240 - 260', + printability: 64, + layerRangeMm: '0.20 - 0.28', + }, + pros: [ + 'Buona resistenza UV e intemperie.', + 'Adatto a componenti outdoor funzionali.', + ], + cons: [ + 'Richiede controllo termico in stampa.', + 'Puo warpare senza setup adeguato.', + ], + idealFor: ['Cover esterne', 'Staffe outdoor', 'Parti esposte al sole'], + sources: [ + { + label: 'Wikipedia - Acrylonitrile styrene acrylate', + kind: 'Wikipedia', + url: 'https://en.wikipedia.org/wiki/Acrylonitrile_styrene_acrylate', + }, + { + label: 'Bambu Lab - ASA', + kind: 'Vendor', + url: 'https://eu.store.bambulab.com/it/products/asa-filament', + }, + { + label: 'Ultimaker - ASA', + kind: 'Scheda tecnica', + url: 'https://ultimaker.com/materials/method-series-asa/', + }, + ], + }, + { + id: 'pc', + name: 'PC', + summary: + 'Materiale tecnico ad alta resistenza meccanica e termica per parti robuste.', + qualityTips: [ + 'Preferibile camera calda e materiale ben asciutto.', + 'Layer 0.20-0.28 mm riduce stress residui rispetto a layer sottili.', + ], + metrics: { + priceChfKg: 39, + densityGcm3: 1.2, + tensileMpa: 55, + modulusGpa: 2.11, + elongationPct: 3.8, + hdtC: 112, + extrusionC: '260 - 280', + printability: 47, + layerRangeMm: '0.20 - 0.28', + }, + pros: [ + 'Molto resistente all urto e al calore.', + 'Stabilita dimensionale elevata su pezzi tecnici.', + ], + cons: [ + 'Stampa impegnativa con alta temperatura.', + 'Setup non ottimale porta facilmente a deformazioni.', + ], + idealFor: [ + 'Alloggiamenti tecnici', + 'Staffe ad alto carico', + 'Parti vicino a fonti di calore', + ], + sources: [ + { + label: 'Wikipedia - Polycarbonate', + kind: 'Wikipedia', + url: 'https://en.wikipedia.org/wiki/Polycarbonate', + }, + { + label: 'Bambu Lab - PC', + kind: 'Vendor', + url: 'https://eu.store.bambulab.com/it/products/pc-filament', + }, + { + label: 'Ultimaker - PC', + kind: 'Scheda tecnica', + url: 'https://ultimaker.com/materials/s-series-pc/', + }, + ], + }, + { + id: 'pa12-cf', + name: 'PA12-CF', + summary: + 'Nylon rinforzato carbonio orientato a rigidita e stabilita su parti funzionali.', + qualityTips: [ + 'Materiale e ambiente devono essere asciutti prima della stampa.', + 'Layer 0.20-0.28 mm con ugello temprato e setup consigliato.', + ], + metrics: { + priceChfKg: 50, + densityGcm3: 1.06, + tensileMpa: 60, + modulusGpa: 3.3, + elongationPct: 16, + hdtC: 185, + extrusionC: '260 - 290', + printability: 42, + layerRangeMm: '0.20 - 0.28', + }, + pros: [ + 'Ottimo rapporto rigidita/peso.', + 'Buona stabilita dimensionale per fixture tecniche.', + ], + cons: [ + 'Costo e complessita stampa superiori ai materiali base.', + 'Richiede gestione umidita e ugello adeguato.', + ], + idealFor: [ + 'Jig e fixture', + 'Parti strutturali leggere', + 'Componenti meccanici tecnici', + ], + sources: [ + { + label: 'Wikipedia - Nylon 12', + kind: 'Wikipedia', + url: 'https://en.wikipedia.org/wiki/Nylon_12', + }, + { + label: 'Ultimaker - Nylon 12 Carbon Fiber', + kind: 'Scheda tecnica', + url: 'https://ultimaker.com/materials/method-series-nylon-12-carbon-fiber/', + }, + { + label: 'Vendor listing - PA12-CF', + kind: 'Vendor', + url: 'https://www.amazon.de/-/en/ERYONE-Carbon-Filament-Printer-Printers/dp/B0CHDS7YD2/', + }, + ], + }, + { + id: 'pet-cf', + name: 'PET-CF', + summary: + 'Materiale tecnico rigido con fibra di carbonio, molto stabile su pezzi di precisione.', + qualityTips: [ + 'Ugello temprato obbligatorio per abrasivita della fibra.', + 'Layer 0.20-0.28 mm bilancia adesione e rigidita finale.', + ], + metrics: { + priceChfKg: 83, + densityGcm3: 1.29, + tensileMpa: 74, + modulusGpa: 4.73, + elongationPct: 4, + hdtC: 205, + extrusionC: '260 - 290', + printability: 39, + layerRangeMm: '0.20 - 0.28', + }, + pros: [ + 'Alte prestazioni meccaniche e termiche.', + 'Bassa deformazione su geometrie funzionali.', + ], + cons: [ + 'Abrasivo: serve ugello temprato.', + 'Costo alto, non ideale per prototipi economici.', + ], + idealFor: [ + 'Componenti tecnici di precisione', + 'Supporti rigidi', + 'Parti con stabilita termica elevata', + ], + sources: [ + { + label: 'Wikipedia - Polyethylene terephthalate', + kind: 'Wikipedia', + url: 'https://en.wikipedia.org/wiki/Polyethylene_terephthalate', + }, + { + label: 'Wikipedia - Carbon fiber reinforced polymer', + kind: 'Wikipedia', + url: 'https://en.wikipedia.org/wiki/Carbon_fiber_reinforced_polymer', + }, + { + label: 'Bambu Lab - PET-CF', + kind: 'Vendor', + url: 'https://eu.store.bambulab.com/it/products/pet-cf', + }, + ], + }, +]; + +const RADAR_AXES: readonly RadarAxis[] = [ + { + id: 'economy', + label: 'Economicita', + description: + 'Indice calcolato dal prezzo al kg: valore alto = costo inferiore.', + unit: 'CHF/kg', + lowerIsBetter: true, + accessor: (material) => material.metrics.priceChfKg, + }, + { + id: 'printability', + label: 'Printability', + description: + 'Indice pratico di stampabilita (warping, sensibilita umidita, stabilita processo).', + unit: 'score', + accessor: (material) => material.metrics.printability, + }, + { + id: 'tensile', + label: 'Resistenza', + description: 'Resistenza a trazione del materiale (MPa).', + unit: 'MPa', + accessor: (material) => material.metrics.tensileMpa, + }, + { + id: 'modulus', + label: 'Rigidita', + description: 'Modulo elastico (GPa).', + unit: 'GPa', + accessor: (material) => material.metrics.modulusGpa, + }, + { + id: 'elongation', + label: 'Flessibilita', + description: 'Allungamento a rottura (%).', + unit: '%', + accessor: (material) => material.metrics.elongationPct, + }, + { + id: 'hdt', + label: 'Temperatura', + description: 'HDT: temperatura di deformazione (C).', + unit: 'C', + accessor: (material) => material.metrics.hdtC, + }, +]; + +const QUALITY_GUIDES: readonly QualityGuide[] = [ + { + id: 'layer-height', + title: 'Altezza layer', + recommendation: '0.12-0.16 fine | 0.20 standard | 0.28-0.32 draft', + explanation: + 'Layer basso = migliore dettaglio e superfici piu uniformi; layer alto = stampa piu veloce.', + practicalEffect: + 'Ridurre layer height aumenta tempi e puo migliorare precisione visiva su curve e scritte.', + }, + { + id: 'nozzle-diameter', + title: 'Diametro ugello', + recommendation: '0.4 mm standard | 0.6/0.8 mm per pezzi robusti', + explanation: + 'Ugello piccolo migliora i dettagli; ugello grande aumenta produttivita e portata.', + practicalEffect: + 'Con materiali caricati fibra (CF) e preferibile ugello temprato e diametri >=0.4 mm.', + }, + { + id: 'infill', + title: 'Infill e perimetri', + recommendation: '15-25% prototipi | 30-45% parti funzionali', + explanation: + 'La robustezza dipende spesso piu dai perimetri che dal solo infill.', + practicalEffect: + 'Aumentare perimetri migliora resistenza locale senza far esplodere i tempi come infill estremi.', + }, + { + id: 'speed-temperature', + title: 'Velocita e temperatura', + recommendation: 'Riduci velocita su tecnici (PC/CF/TPU), mantieni temperatura stabile', + explanation: + 'Materiali difficili richiedono processo piu lento e termicamente controllato.', + practicalEffect: + 'Velocita troppo alta genera sotto-estrusione, adesione layer debole e difetti superficiali.', + }, +]; + +const QUALITY_VISUAL_GUIDES: readonly QualityVisualGuide[] = [ + { + id: 'layer-012', + category: 'Layer', + title: 'Layer 0.12 mm', + objectExample: 'Miniatura o testo piccolo con dettagli fini.', + bestFor: 'Massimo dettaglio superficiale.', + tradeoff: 'Tempo di stampa alto.', + calculatorRead: 'Nel calcolatore: qualita piu alta, tempo piu lungo.', + usageKey: 'guide-layer-012', + }, + { + id: 'layer-020', + category: 'Layer', + title: 'Layer 0.20 mm', + objectExample: 'Pezzo funzionale standard o cover tecnica.', + bestFor: 'Compromesso qualita/tempo.', + tradeoff: 'Dettaglio inferiore al 0.12 mm.', + calculatorRead: 'Nel calcolatore: profilo bilanciato.', + usageKey: 'guide-layer-020', + }, + { + id: 'layer-028', + category: 'Layer', + title: 'Layer 0.28 mm', + objectExample: 'Staffa di test o prototipo rapido.', + bestFor: 'Riduzione tempi e pezzi voluminosi.', + tradeoff: 'Superficie piu visibile a scalini.', + calculatorRead: 'Nel calcolatore: tempo piu corto, finitura piu bassa.', + usageKey: 'guide-layer-028', + }, + { + id: 'nozzle-025', + category: 'Ugello', + title: 'Ugello 0.25 mm', + objectExample: 'Scritta piccola o geometria sottile.', + bestFor: 'Dettagli molto piccoli.', + tradeoff: 'Tempi piu lunghi e portata ridotta.', + calculatorRead: 'Nel calcolatore favorisce precisione, non produttivita.', + usageKey: 'guide-nozzle-025', + }, + { + id: 'nozzle-060', + category: 'Ugello', + title: 'Ugello 0.60 mm', + objectExample: 'Parti robuste, supporti, staffe.', + bestFor: 'Resistenza e velocita su pezzi funzionali.', + tradeoff: 'Dettaglio fine ridotto.', + calculatorRead: 'Nel calcolatore favorisce robustezza e tempi migliori.', + usageKey: 'guide-nozzle-060', + }, + { + id: 'infill-15', + category: 'Infill', + title: 'Infill 15% + 2/3 perimetri', + objectExample: 'Oggetto estetico o mockup leggero.', + bestFor: 'Ridurre peso e tempo.', + tradeoff: 'Resistenza strutturale limitata.', + calculatorRead: 'Nel calcolatore: robustezza piu bassa, tempo piu breve.', + usageKey: 'guide-infill-15', + }, + { + id: 'infill-40', + category: 'Infill', + title: 'Infill 40% + 4 perimetri', + objectExample: 'Componente con carico meccanico.', + bestFor: 'Migliore resistenza funzionale.', + tradeoff: 'Peso e tempo di stampa superiori.', + calculatorRead: 'Nel calcolatore: robustezza piu alta, tempo piu lungo.', + usageKey: 'guide-infill-40', + }, +]; + +const CALCULATOR_RULES: readonly CalculatorRule[] = [ + { + metric: 'Qualita superficie', + whatItMeans: 'Quanto il pezzo appare pulito su curve, spigoli e testi.', + whenHigh: 'Layer piu basso e setup orientato al dettaglio.', + whenLow: 'Layer piu alto o impostazioni orientate alla velocita.', + }, + { + metric: 'Robustezza stimata', + whatItMeans: 'Capacita del pezzo di sopportare uso meccanico.', + whenHigh: 'Infill/piu perimetri e materiali strutturali.', + whenLow: 'Pezzo leggero con pochi perimetri o infill ridotto.', + }, + { + metric: 'Tempo relativo', + whatItMeans: 'Durata stimata rispetto a un profilo standard.', + whenHigh: 'Profilo lento e piu preciso o molto robusto.', + whenLow: 'Profilo rapido orientato a prototipazione.', + }, +]; + +const SERIES_STYLES = [ + { stroke: '#c23b22', fill: 'rgba(194, 59, 34, 0.22)' }, + { stroke: '#2663d3', fill: 'rgba(38, 99, 211, 0.20)' }, + { stroke: '#0f8f6f', fill: 'rgba(15, 143, 111, 0.19)' }, + { stroke: '#8a44c9', fill: 'rgba(138, 68, 201, 0.18)' }, + { stroke: '#c77510', fill: 'rgba(199, 117, 16, 0.17)' }, + { stroke: '#125067', fill: 'rgba(18, 80, 103, 0.16)' }, + { stroke: '#8f2f5f', fill: 'rgba(143, 47, 95, 0.16)' }, + { stroke: '#3b7f1f', fill: 'rgba(59, 127, 31, 0.16)' }, +] as const; + +const CHART_SIZE = 460; +const CHART_CENTER = CHART_SIZE / 2; +const CHART_RADIUS = 156; +const CHART_LEVELS = 5; +const CHART_INNER_RATIO = 0.09; +const EMPTY_MEDIA_COLLECTIONS: PublicMediaUsageCollectionMap = {}; +const MAX_COMPARE_COUNT = 6; + +@Component({ + selector: 'app-materials-page', + standalone: true, + imports: [CommonModule], + templateUrl: './materials-page.component.html', + styleUrl: './materials-page.component.scss', +}) +export class MaterialsPageComponent { + private readonly publicMediaService = inject(PublicMediaService); + + readonly materials = MATERIALS; + readonly radarAxes = RADAR_AXES; + readonly maxCompareCount = MAX_COMPARE_COUNT; + readonly qualityGuides = QUALITY_GUIDES; + readonly calculatorRules = CALCULATOR_RULES; + + readonly selectedMaterialIds = signal([ + 'pla-basic', + 'asa', + 'pet-cf', + ]); + readonly hoveredMaterialId = signal(null); + + private readonly pageMediaRequests = [ + ...MATERIALS.map((material) => ({ + usageType: 'MATERIALS_PAGE' as const, + usageKey: `material-${material.id}`, + })), + ...QUALITY_VISUAL_GUIDES.map((guide) => ({ + usageType: 'MATERIALS_PAGE' as const, + usageKey: guide.usageKey, + })), + ]; + + private readonly mediaByUsage = toSignal( + this.publicMediaService.getUsageCollections(this.pageMediaRequests), + { initialValue: EMPTY_MEDIA_COLLECTIONS }, + ); + + readonly selectedCount = computed(() => this.selectedMaterialIds().length); + + readonly selectedMaterials = computed(() => { + const selectedIds = new Set(this.selectedMaterialIds()); + return MATERIALS.filter((material) => selectedIds.has(material.id)); + }); + + readonly selectedCards = computed(() => + this.selectedMaterials().map((material) => ({ + material, + image: this.resolveMaterialImage(material.id), + })), + ); + + readonly qualityVisualCards = computed(() => + QUALITY_VISUAL_GUIDES.map((guide) => ({ + ...guide, + image: this.resolveUsageImage(guide.usageKey), + })), + ); + + readonly selectionHint = computed(() => { + return `Materiali selezionati: ${this.selectedCount()} / ${MAX_COMPARE_COUNT}. Se superi il limite, viene sostituito il meno recente.`; + }); + + readonly ringPolygons = computed(() => { + const polygons: string[] = []; + for (let level = 1; level <= CHART_LEVELS; level += 1) { + polygons.push(this.polygonPoints(this.visualRatio(level / CHART_LEVELS))); + } + return polygons; + }); + + readonly axisGuides = computed(() => + RADAR_AXES.map((axis, index) => { + const inner = this.pointForRatio(index, CHART_INNER_RATIO); + const outer = this.pointForRatio(index, 1); + const label = this.pointForRatio(index, 1.18); + const anchor: 'start' | 'middle' | 'end' = + Math.abs(label.x - CHART_CENTER) < 12 + ? 'middle' + : label.x > CHART_CENTER + ? 'start' + : 'end'; + + return { + id: axis.id, + fromX: inner.x, + fromY: inner.y, + x: outer.x, + y: outer.y, + labelX: label.x, + labelY: label.y, + labelAnchor: anchor, + }; + }), + ); + + readonly radarSeries = computed(() => + this.selectedMaterials().map((material, index) => { + const style = SERIES_STYLES[index % SERIES_STYLES.length]; + const values: RadarPoint[] = RADAR_AXES.map((axis, axisIndex) => { + const { rawValue, score } = this.axisScore(material, axis); + const point = this.pointForScore(axisIndex, score); + return { + axis, + score, + rawValue, + x: point.x, + y: point.y, + }; + }); + + return { + material, + color: style.stroke, + fill: style.fill, + points: values.map((value) => `${value.x},${value.y}`).join(' '), + values, + }; + }), + ); + + readonly comparisonRows = computed(() => { + const selected = this.selectedMaterials(); + const format = (value: number, fractionDigits: number): string => + value.toFixed(fractionDigits); + + return [ + { + label: 'Printability [score 0-100]', + values: selected.map((material) => + format(material.metrics.printability, 0), + ), + }, + { + label: 'Layer consigliato [mm]', + values: selected.map((material) => material.metrics.layerRangeMm), + }, + { + label: 'Prezzo [CHF/kg]', + values: selected.map((material) => format(material.metrics.priceChfKg, 0)), + }, + { + label: 'Densita [g/cm3]', + values: selected.map((material) => format(material.metrics.densityGcm3, 2)), + }, + { + label: 'Resistenza a trazione [MPa]', + values: selected.map((material) => format(material.metrics.tensileMpa, 0)), + }, + { + label: 'Modulo elastico [GPa]', + values: selected.map((material) => format(material.metrics.modulusGpa, 2)), + }, + { + label: 'Allungamento a rottura [%]', + values: selected.map((material) => format(material.metrics.elongationPct, 1)), + }, + { + label: 'HDT [C]', + values: selected.map((material) => format(material.metrics.hdtC, 0)), + }, + { + label: 'Temp. estrusione [C]', + values: selected.map((material) => material.metrics.extrusionC), + }, + ]; + }); + + readonly allSources = computed(() => { + const unique = new Map(); + MATERIALS.forEach((material) => { + material.sources.forEach((source) => { + unique.set(source.url, source); + }); + }); + return Array.from(unique.values()); + }); + + isSelected(materialId: string): boolean { + return this.selectedMaterialIds().includes(materialId); + } + + canSelect(_materialId: string): boolean { + return true; + } + + toggleMaterial(materialId: string): void { + const selected = this.selectedMaterialIds(); + + if (selected.includes(materialId)) { + const next = selected.filter((id) => id !== materialId); + this.selectedMaterialIds.set(next.length > 0 ? next : [materialId]); + return; + } + + const next = [...selected, materialId]; + while (next.length > MAX_COMPARE_COUNT) { + next.shift(); + } + this.selectedMaterialIds.set(next); + } + + setHoveredMaterial(materialId: string | null): void { + this.hoveredMaterialId.set(materialId); + } + + legendDotColor(materialId: string): string { + const index = this.selectedMaterialIds().indexOf(materialId); + return index >= 0 + ? SERIES_STYLES[index % SERIES_STYLES.length].stroke + : '#9aa2ad'; + } + + sourceDomain(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ''); + } catch { + return url; + } + } + + sourceKindClass(kind: MaterialSource['kind']): string { + switch (kind) { + case 'Wikipedia': + return 'source-pill source-pill--wiki'; + case 'Scheda tecnica': + return 'source-pill source-pill--tech'; + case 'Vendor': + return 'source-pill source-pill--vendor'; + } + } + + trackMaterial(_index: number, material: MaterialRecord): string { + return material.id; + } + + trackSource(_index: number, source: MaterialSource): string { + return source.url; + } + + trackGuide(_index: number, guide: QualityGuide): string { + return guide.id; + } + + trackVisualGuide(_index: number, guide: QualityVisualCard): string { + return guide.id; + } + + private resolveMaterialImage(materialId: string): PublicMediaDisplayImage | null { + return this.resolveUsageImage(`material-${materialId}`); + } + + private resolveUsageImage(usageKeyRaw: string): PublicMediaDisplayImage | null { + const usageKey = buildPublicMediaUsageScopeKey('MATERIALS_PAGE', usageKeyRaw); + const usageItems = this.mediaByUsage()[usageKey] ?? []; + const primary = this.publicMediaService.pickPrimaryUsage(usageItems); + return primary + ? this.publicMediaService.toDisplayImage(primary, 'card') + : null; + } + + private axisScore(material: MaterialRecord, axis: RadarAxis): { + rawValue: number; + score: number; + } { + const rawValue = axis.accessor(material); + const axisValues = MATERIALS.map(axis.accessor); + const min = Math.min(...axisValues); + const max = Math.max(...axisValues); + + if (max === min) { + return { rawValue, score: 100 }; + } + + const normalized = ((rawValue - min) / (max - min)) * 100; + const score = axis.lowerIsBetter ? 100 - normalized : normalized; + return { rawValue, score: Math.max(0, Math.min(100, score)) }; + } + + private polygonPoints(ratio: number): string { + return RADAR_AXES.map((_, index) => { + const point = this.pointForRatio(index, ratio); + return `${point.x},${point.y}`; + }).join(' '); + } + + private pointForScore(axisIndex: number, score: number): { + x: number; + y: number; + } { + return this.pointForRatio(axisIndex, this.visualRatio(score / 100)); + } + + private pointForRatio(axisIndex: number, ratio: number): { + x: number; + y: number; + } { + const angle = -Math.PI / 2 + (axisIndex * 2 * Math.PI) / RADAR_AXES.length; + return { + x: CHART_CENTER + Math.cos(angle) * CHART_RADIUS * ratio, + y: CHART_CENTER + Math.sin(angle) * CHART_RADIUS * ratio, + }; + } + + private visualRatio(normalizedRatio: number): number { + const clampedRatio = this.clamp(normalizedRatio, 0, 1); + return CHART_INNER_RATIO + clampedRatio * (1 - CHART_INNER_RATIO); + } + + private clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); + } + + protected readonly chartSize = CHART_SIZE; +}