@dirsigler/techradar 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +277 -0
- package/config.ts +54 -0
- package/index.ts +11 -0
- package/integration.ts +85 -0
- package/package.json +43 -0
- package/schemas.ts +13 -0
- package/src/components/MovedIndicator.astro +10 -0
- package/src/components/Radar.astro +266 -0
- package/src/components/RadarLegend.astro +120 -0
- package/src/components/RingBadge.astro +49 -0
- package/src/components/TechnologyCard.astro +46 -0
- package/src/components/ThemeToggle.astro +115 -0
- package/src/layouts/Base.astro +149 -0
- package/src/lib/radar.ts +124 -0
- package/src/pages/404.astro +34 -0
- package/src/pages/index.astro +206 -0
- package/src/pages/segments/[segment].astro +83 -0
- package/src/pages/technology/[...slug].astro +112 -0
- package/src/styles/global.css +37 -0
- package/src/themes/catppuccin-mocha.css +55 -0
- package/src/themes/default.css +171 -0
- package/virtual.d.ts +9 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
---
|
|
2
|
+
import config from 'virtual:techradar/config';
|
|
3
|
+
import themeCSS from 'virtual:techradar/theme';
|
|
4
|
+
import ThemeToggle from '../components/ThemeToggle.astro';
|
|
5
|
+
import '../styles/global.css';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
title?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { title = config.title, description = 'Technology Radar — tracking technology adoption across our organization' } = Astro.props;
|
|
13
|
+
const base = import.meta.env.BASE_URL.endsWith('/') ? import.meta.env.BASE_URL : import.meta.env.BASE_URL + '/';
|
|
14
|
+
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
<!doctype html>
|
|
18
|
+
<html lang="en">
|
|
19
|
+
<head>
|
|
20
|
+
<meta charset="utf-8" />
|
|
21
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
22
|
+
<meta name="description" content={description} />
|
|
23
|
+
<link rel="canonical" href={canonicalUrl} />
|
|
24
|
+
|
|
25
|
+
<!-- Open Graph -->
|
|
26
|
+
<meta property="og:type" content="website" />
|
|
27
|
+
<meta property="og:title" content={title} />
|
|
28
|
+
<meta property="og:description" content={description} />
|
|
29
|
+
<meta property="og:url" content={canonicalUrl} />
|
|
30
|
+
<meta property="og:image" content={new URL(`${base}og-image.png`, Astro.site)} />
|
|
31
|
+
<meta property="og:image:width" content="1200" />
|
|
32
|
+
<meta property="og:image:height" content="630" />
|
|
33
|
+
|
|
34
|
+
<!-- Twitter -->
|
|
35
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
36
|
+
<meta name="twitter:title" content={title} />
|
|
37
|
+
<meta name="twitter:description" content={description} />
|
|
38
|
+
<meta name="twitter:image" content={new URL(`${base}og-image.png`, Astro.site)} />
|
|
39
|
+
|
|
40
|
+
<link rel="icon" type="image/svg+xml" href={`${base}favicon.svg`} />
|
|
41
|
+
<title>{title}</title>
|
|
42
|
+
<style set:html={themeCSS}></style>
|
|
43
|
+
<script is:inline>
|
|
44
|
+
// Prevent flash of wrong theme — runs before paint
|
|
45
|
+
(function() {
|
|
46
|
+
var t = localStorage.getItem('theme');
|
|
47
|
+
if (t === 'light' || t === 'dark') {
|
|
48
|
+
document.documentElement.setAttribute('data-theme', t);
|
|
49
|
+
}
|
|
50
|
+
var isDark = t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
51
|
+
if (isDark) document.documentElement.classList.add('is-dark');
|
|
52
|
+
})();
|
|
53
|
+
</script>
|
|
54
|
+
</head>
|
|
55
|
+
<body class="flex min-h-screen flex-col">
|
|
56
|
+
<header style="border-bottom: 1px solid var(--radar-border);">
|
|
57
|
+
<nav class="mx-auto flex max-w-6xl items-center justify-between px-6 py-5">
|
|
58
|
+
<a href={base} class="nav-link flex items-center gap-3 text-xl font-bold tracking-tight">
|
|
59
|
+
{config.logo && (
|
|
60
|
+
<img src={`${base}${config.logo.replace(/^\//, '')}`} alt={config.title} class="h-8 w-auto" />
|
|
61
|
+
)}
|
|
62
|
+
{config.title}
|
|
63
|
+
</a>
|
|
64
|
+
<ThemeToggle />
|
|
65
|
+
</nav>
|
|
66
|
+
</header>
|
|
67
|
+
|
|
68
|
+
<main class="mx-auto max-w-6xl px-6 py-10 w-full">
|
|
69
|
+
<slot />
|
|
70
|
+
</main>
|
|
71
|
+
|
|
72
|
+
<footer class="footer mt-auto" style="border-top: 1px solid var(--radar-border);">
|
|
73
|
+
<div class="mx-auto max-w-6xl px-6 py-6">
|
|
74
|
+
<div class="flex flex-col items-center gap-3 sm:flex-row sm:justify-between">
|
|
75
|
+
<span class="footer-text">
|
|
76
|
+
© <a href="https://github.com/dirsigler/techradar" target="_blank" rel="noopener noreferrer" class="footer-link">Dennis Irsigler</a>
|
|
77
|
+
·
|
|
78
|
+
{config.repositoryUrl ? (
|
|
79
|
+
<a href={config.repositoryUrl} target="_blank" rel="noopener noreferrer" class="footer-link">MIT License</a>
|
|
80
|
+
) : (
|
|
81
|
+
<Fragment>MIT License</Fragment>
|
|
82
|
+
)}
|
|
83
|
+
{config.footerText && <Fragment> · <span set:html={config.footerText} /></Fragment>}
|
|
84
|
+
</span>
|
|
85
|
+
|
|
86
|
+
{config.socialLinks && config.socialLinks.length > 0 && (
|
|
87
|
+
<div class="flex items-center gap-4">
|
|
88
|
+
{config.socialLinks.map((link) => (
|
|
89
|
+
<a
|
|
90
|
+
href={link.href}
|
|
91
|
+
target="_blank"
|
|
92
|
+
rel="noopener noreferrer"
|
|
93
|
+
class="social-link"
|
|
94
|
+
aria-label={link.label}
|
|
95
|
+
>
|
|
96
|
+
{link.icon ? (
|
|
97
|
+
<svg viewBox="0 0 24 24" class="h-5 w-5" fill="currentColor" aria-hidden="true">
|
|
98
|
+
<path d={link.icon} />
|
|
99
|
+
</svg>
|
|
100
|
+
) : (
|
|
101
|
+
<span>{link.label}</span>
|
|
102
|
+
)}
|
|
103
|
+
</a>
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</footer>
|
|
110
|
+
</body>
|
|
111
|
+
</html>
|
|
112
|
+
|
|
113
|
+
<style>
|
|
114
|
+
.nav-link {
|
|
115
|
+
color: var(--radar-text);
|
|
116
|
+
text-decoration: none;
|
|
117
|
+
transition: color 0.15s;
|
|
118
|
+
}
|
|
119
|
+
.nav-link:hover {
|
|
120
|
+
color: var(--radar-link);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.footer {
|
|
124
|
+
text-align: center;
|
|
125
|
+
font-size: 0.875rem;
|
|
126
|
+
color: var(--radar-text-faint);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.footer-text {
|
|
130
|
+
color: var(--radar-text-faint);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.footer-link {
|
|
134
|
+
color: var(--radar-text-faint);
|
|
135
|
+
text-decoration: none;
|
|
136
|
+
transition: color 0.15s;
|
|
137
|
+
}
|
|
138
|
+
.footer-link:hover {
|
|
139
|
+
color: var(--radar-text-muted);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.social-link {
|
|
143
|
+
color: var(--radar-text-muted);
|
|
144
|
+
transition: color 0.15s;
|
|
145
|
+
}
|
|
146
|
+
.social-link:hover {
|
|
147
|
+
color: var(--radar-text);
|
|
148
|
+
}
|
|
149
|
+
</style>
|
package/src/lib/radar.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
export const RINGS = ['adopt', 'trial', 'assess', 'hold'] as const;
|
|
2
|
+
export type Ring = (typeof RINGS)[number];
|
|
3
|
+
|
|
4
|
+
export const RING_RADII: Record<Ring, { inner: number; outer: number }> = {
|
|
5
|
+
adopt: { inner: 0, outer: 100 },
|
|
6
|
+
trial: { inner: 100, outer: 200 },
|
|
7
|
+
assess: { inner: 200, outer: 300 },
|
|
8
|
+
hold: { inner: 300, outer: 400 },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const RING_LABELS: Record<Ring, string> = {
|
|
12
|
+
adopt: 'Adopt',
|
|
13
|
+
trial: 'Trial',
|
|
14
|
+
assess: 'Assess',
|
|
15
|
+
hold: 'Hold',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const RING_DESCRIPTIONS: Record<Ring, string> = {
|
|
19
|
+
adopt: 'Technologies we have high confidence in and recommend for broad use across projects.',
|
|
20
|
+
trial: 'Technologies worth pursuing. We see potential and recommend trying them on projects that can handle the risk.',
|
|
21
|
+
assess: 'Technologies worth exploring to understand how they might affect our work. Not yet ready for trial.',
|
|
22
|
+
hold: 'Technologies we have reservations about. They should not be used for new projects but may exist in legacy systems.',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const CENTER = 400;
|
|
26
|
+
const PADDING = 12;
|
|
27
|
+
|
|
28
|
+
/** Simple deterministic hash from a string to a number in [0, 1). */
|
|
29
|
+
function hash(str: string): number {
|
|
30
|
+
let h = 0;
|
|
31
|
+
for (let i = 0; i < str.length; i++) {
|
|
32
|
+
h = ((h << 5) - h + str.charCodeAt(i)) | 0;
|
|
33
|
+
}
|
|
34
|
+
return (Math.abs(h) % 10000) / 10000;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Returns the angular range (in radians) for a given quadrant order (1-4). */
|
|
38
|
+
export function quadrantAngles(order: number): { start: number; end: number } {
|
|
39
|
+
const mapping: Record<number, { start: number; end: number }> = {
|
|
40
|
+
1: { start: (3 * Math.PI) / 2, end: 2 * Math.PI },
|
|
41
|
+
2: { start: 0, end: Math.PI / 2 },
|
|
42
|
+
3: { start: Math.PI / 2, end: Math.PI },
|
|
43
|
+
4: { start: Math.PI, end: (3 * Math.PI) / 2 },
|
|
44
|
+
};
|
|
45
|
+
return mapping[order] ?? mapping[1];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface DotPosition {
|
|
49
|
+
x: number;
|
|
50
|
+
y: number;
|
|
51
|
+
title: string;
|
|
52
|
+
ring: Ring;
|
|
53
|
+
color: string;
|
|
54
|
+
moved: number;
|
|
55
|
+
slug: string;
|
|
56
|
+
segment: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Position all technology dots on the radar, avoiding collisions.
|
|
61
|
+
*/
|
|
62
|
+
export function positionDots(
|
|
63
|
+
technologies: Array<{
|
|
64
|
+
title: string;
|
|
65
|
+
ring: Ring;
|
|
66
|
+
moved: number;
|
|
67
|
+
slug: string;
|
|
68
|
+
segment: string;
|
|
69
|
+
color: string;
|
|
70
|
+
order: number;
|
|
71
|
+
}>,
|
|
72
|
+
): DotPosition[] {
|
|
73
|
+
const placed: DotPosition[] = [];
|
|
74
|
+
|
|
75
|
+
for (const tech of technologies) {
|
|
76
|
+
const { inner, outer } = RING_RADII[tech.ring];
|
|
77
|
+
const angles = quadrantAngles(tech.order);
|
|
78
|
+
const anglePadding = 0.08;
|
|
79
|
+
const radiusPadding = PADDING;
|
|
80
|
+
|
|
81
|
+
let bestX = CENTER;
|
|
82
|
+
let bestY = CENTER;
|
|
83
|
+
let bestDist = -1;
|
|
84
|
+
|
|
85
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
86
|
+
const h1 = hash(tech.title + attempt);
|
|
87
|
+
const h2 = hash(attempt + tech.title + 'y');
|
|
88
|
+
|
|
89
|
+
const r =
|
|
90
|
+
inner + radiusPadding + h1 * (outer - inner - 2 * radiusPadding);
|
|
91
|
+
const a =
|
|
92
|
+
angles.start + anglePadding + h2 * (angles.end - angles.start - 2 * anglePadding);
|
|
93
|
+
|
|
94
|
+
const cx = CENTER + r * Math.cos(a);
|
|
95
|
+
const cy = CENTER + r * Math.sin(a);
|
|
96
|
+
|
|
97
|
+
let minDist = Infinity;
|
|
98
|
+
for (const p of placed) {
|
|
99
|
+
const dx = cx - p.x;
|
|
100
|
+
const dy = cy - p.y;
|
|
101
|
+
minDist = Math.min(minDist, Math.sqrt(dx * dx + dy * dy));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (minDist > bestDist) {
|
|
105
|
+
bestDist = minDist;
|
|
106
|
+
bestX = cx;
|
|
107
|
+
bestY = cy;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
placed.push({
|
|
112
|
+
x: bestX,
|
|
113
|
+
y: bestY,
|
|
114
|
+
title: tech.title,
|
|
115
|
+
ring: tech.ring,
|
|
116
|
+
color: tech.color,
|
|
117
|
+
moved: tech.moved,
|
|
118
|
+
slug: tech.slug,
|
|
119
|
+
segment: tech.segment,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return placed;
|
|
124
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Base from '../layouts/Base.astro';
|
|
3
|
+
|
|
4
|
+
const base = import.meta.env.BASE_URL.endsWith('/') ? import.meta.env.BASE_URL : import.meta.env.BASE_URL + '/';
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<Base title="Page Not Found — Tech Radar" description="The page you're looking for doesn't exist.">
|
|
8
|
+
<div class="flex flex-col items-center justify-center py-20 text-center">
|
|
9
|
+
<h1 class="text-6xl font-bold" style="color: var(--radar-text);">404</h1>
|
|
10
|
+
<p class="mt-4 text-lg" style="color: var(--radar-text-muted);">
|
|
11
|
+
The page you're looking for doesn't exist.
|
|
12
|
+
</p>
|
|
13
|
+
<a href={base} class="back-link mt-8">
|
|
14
|
+
Back to Tech Radar
|
|
15
|
+
</a>
|
|
16
|
+
</div>
|
|
17
|
+
</Base>
|
|
18
|
+
|
|
19
|
+
<style>
|
|
20
|
+
.back-link {
|
|
21
|
+
display: inline-flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
padding: 0.625rem 1.5rem;
|
|
24
|
+
border-radius: 0.5rem;
|
|
25
|
+
font-weight: 500;
|
|
26
|
+
background-color: var(--radar-active-bg);
|
|
27
|
+
color: var(--radar-active-text);
|
|
28
|
+
text-decoration: none;
|
|
29
|
+
transition: opacity 0.15s;
|
|
30
|
+
}
|
|
31
|
+
.back-link:hover {
|
|
32
|
+
opacity: 0.9;
|
|
33
|
+
}
|
|
34
|
+
</style>
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection } from 'astro:content';
|
|
3
|
+
import Base from '../layouts/Base.astro';
|
|
4
|
+
import Radar from '../components/Radar.astro';
|
|
5
|
+
import RadarLegend from '../components/RadarLegend.astro';
|
|
6
|
+
import { RINGS, RING_LABELS, RING_DESCRIPTIONS } from '../lib/radar';
|
|
7
|
+
import config from 'virtual:techradar/config';
|
|
8
|
+
|
|
9
|
+
const base = import.meta.env.BASE_URL.endsWith('/') ? import.meta.env.BASE_URL : import.meta.env.BASE_URL + '/';
|
|
10
|
+
|
|
11
|
+
const segmentEntries = await getCollection('segments');
|
|
12
|
+
const technologyEntries = await getCollection('technologies');
|
|
13
|
+
|
|
14
|
+
// Build segment data sorted by order
|
|
15
|
+
const segments = segmentEntries
|
|
16
|
+
.map((entry) => ({
|
|
17
|
+
title: entry.data.title,
|
|
18
|
+
slug: entry.id.replace(/\/index$/, '').replace(/^index$/, entry.id),
|
|
19
|
+
color: entry.data.color,
|
|
20
|
+
order: entry.data.order,
|
|
21
|
+
}))
|
|
22
|
+
.sort((a, b) => a.order - b.order);
|
|
23
|
+
|
|
24
|
+
// Build a lookup from segment slug to segment data
|
|
25
|
+
const segmentMap = new Map(segments.map((s) => [s.slug, s]));
|
|
26
|
+
|
|
27
|
+
// Build technology data with segment info
|
|
28
|
+
const technologies = technologyEntries
|
|
29
|
+
.map((entry) => {
|
|
30
|
+
const parts = entry.id.split('/');
|
|
31
|
+
const segmentSlug = parts.slice(0, -1).join('/');
|
|
32
|
+
const seg = segmentMap.get(segmentSlug);
|
|
33
|
+
return {
|
|
34
|
+
title: entry.data.title,
|
|
35
|
+
ring: entry.data.ring,
|
|
36
|
+
moved: entry.data.moved,
|
|
37
|
+
slug: entry.id,
|
|
38
|
+
segment: segmentSlug,
|
|
39
|
+
color: seg?.color ?? '#6b7280',
|
|
40
|
+
order: seg?.order ?? 1,
|
|
41
|
+
href: `${base}technology/${entry.id}/`,
|
|
42
|
+
};
|
|
43
|
+
})
|
|
44
|
+
.sort((a, b) => a.title.localeCompare(b.title));
|
|
45
|
+
|
|
46
|
+
const legendSegments = segments.map((s) => ({
|
|
47
|
+
title: s.title,
|
|
48
|
+
slug: s.slug,
|
|
49
|
+
color: s.color,
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
const ringEntries = Object.entries(RING_LABELS) as [string, string][];
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
<Base title={config.title}>
|
|
56
|
+
<div class="text-center mb-10">
|
|
57
|
+
<h1 class="text-4xl font-bold" style="color: var(--radar-text);">{config.title}</h1>
|
|
58
|
+
<p class="mt-3 text-lg" style="color: var(--radar-text-muted);">
|
|
59
|
+
An overview of the technologies, frameworks, processes, and languages we use and evaluate.
|
|
60
|
+
</p>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<!-- Filter bar -->
|
|
64
|
+
<div id="filter-bar" class="mb-8 flex flex-wrap items-center justify-center gap-2">
|
|
65
|
+
<span class="text-sm font-medium mr-1" style="color: var(--radar-text-faint);">Filter:</span>
|
|
66
|
+
|
|
67
|
+
<button data-filter="all" data-type="all" class="filter-btn active">All</button>
|
|
68
|
+
|
|
69
|
+
{segments.map((seg) => (
|
|
70
|
+
<button data-filter={seg.slug} data-type="segment" class="filter-btn">
|
|
71
|
+
<span class="inline-block h-2.5 w-2.5 rounded-full mr-1" style={`background-color: ${seg.color}`}></span>
|
|
72
|
+
{seg.title}
|
|
73
|
+
</button>
|
|
74
|
+
))}
|
|
75
|
+
|
|
76
|
+
<span style="color: var(--radar-border-subtle);" class="mx-1">|</span>
|
|
77
|
+
|
|
78
|
+
{ringEntries.map(([key, label]) => (
|
|
79
|
+
<button data-filter={key} data-type="ring" class="filter-btn">
|
|
80
|
+
{label}
|
|
81
|
+
</button>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<Radar technologies={technologies} segments={segments} />
|
|
86
|
+
|
|
87
|
+
<!-- Ring descriptions -->
|
|
88
|
+
<div class="ring-descriptions">
|
|
89
|
+
{RINGS.map((ring) => (
|
|
90
|
+
<div class="ring-desc">
|
|
91
|
+
<span class="ring-desc-label">{RING_LABELS[ring]}</span>
|
|
92
|
+
<span class="ring-desc-text">{RING_DESCRIPTIONS[ring]}</span>
|
|
93
|
+
</div>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<RadarLegend technologies={technologies} segments={legendSegments} />
|
|
98
|
+
</Base>
|
|
99
|
+
|
|
100
|
+
<style>
|
|
101
|
+
.filter-btn {
|
|
102
|
+
display: inline-flex;
|
|
103
|
+
align-items: center;
|
|
104
|
+
padding: 0.375rem 0.875rem;
|
|
105
|
+
border-radius: 9999px;
|
|
106
|
+
border: 1px solid var(--radar-border-subtle);
|
|
107
|
+
background-color: transparent;
|
|
108
|
+
color: var(--radar-text-muted);
|
|
109
|
+
font-size: 0.875rem;
|
|
110
|
+
font-weight: 500;
|
|
111
|
+
cursor: pointer;
|
|
112
|
+
transition: all 0.15s ease;
|
|
113
|
+
}
|
|
114
|
+
.filter-btn:hover {
|
|
115
|
+
border-color: var(--radar-border-subtle);
|
|
116
|
+
color: var(--radar-text);
|
|
117
|
+
}
|
|
118
|
+
.filter-btn.active {
|
|
119
|
+
background-color: var(--radar-active-bg);
|
|
120
|
+
border-color: var(--radar-active-bg);
|
|
121
|
+
color: var(--radar-active-text);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.ring-descriptions {
|
|
125
|
+
display: grid;
|
|
126
|
+
grid-template-columns: repeat(1, 1fr);
|
|
127
|
+
gap: 1rem;
|
|
128
|
+
margin-top: 2.5rem;
|
|
129
|
+
padding: 1.5rem;
|
|
130
|
+
border-radius: 0.75rem;
|
|
131
|
+
border: 1px solid var(--radar-border);
|
|
132
|
+
background-color: var(--radar-bg-secondary);
|
|
133
|
+
}
|
|
134
|
+
@media (min-width: 640px) {
|
|
135
|
+
.ring-descriptions {
|
|
136
|
+
grid-template-columns: repeat(2, 1fr);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
@media (min-width: 1024px) {
|
|
140
|
+
.ring-descriptions {
|
|
141
|
+
grid-template-columns: repeat(4, 1fr);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
.ring-desc {
|
|
145
|
+
display: flex;
|
|
146
|
+
flex-direction: column;
|
|
147
|
+
gap: 0.25rem;
|
|
148
|
+
}
|
|
149
|
+
.ring-desc-label {
|
|
150
|
+
font-size: 0.875rem;
|
|
151
|
+
font-weight: 600;
|
|
152
|
+
color: var(--radar-text);
|
|
153
|
+
}
|
|
154
|
+
.ring-desc-text {
|
|
155
|
+
font-size: 0.8125rem;
|
|
156
|
+
line-height: 1.5;
|
|
157
|
+
color: var(--radar-text-muted);
|
|
158
|
+
}
|
|
159
|
+
</style>
|
|
160
|
+
|
|
161
|
+
<script>
|
|
162
|
+
const buttons = document.querySelectorAll<HTMLButtonElement>('.filter-btn');
|
|
163
|
+
const dots = document.querySelectorAll<SVGCircleElement>('.radar-dot');
|
|
164
|
+
const legendRows = document.querySelectorAll<HTMLElement>('.legend-row');
|
|
165
|
+
|
|
166
|
+
buttons.forEach((btn) => {
|
|
167
|
+
btn.addEventListener('click', () => {
|
|
168
|
+
// Update active state
|
|
169
|
+
buttons.forEach((b) => b.classList.remove('active'));
|
|
170
|
+
btn.classList.add('active');
|
|
171
|
+
|
|
172
|
+
const filter = btn.dataset.filter!;
|
|
173
|
+
const type = btn.dataset.type!;
|
|
174
|
+
|
|
175
|
+
// Filter radar dots
|
|
176
|
+
dots.forEach((dot) => {
|
|
177
|
+
const link = dot.closest('a') as SVGAElement | null;
|
|
178
|
+
if (!link) return;
|
|
179
|
+
|
|
180
|
+
const segment = dot.getAttribute('data-segment') ?? '';
|
|
181
|
+
const ring = dot.getAttribute('data-ring') ?? '';
|
|
182
|
+
|
|
183
|
+
let visible = true;
|
|
184
|
+
if (type === 'segment') visible = segment === filter;
|
|
185
|
+
else if (type === 'ring') visible = ring === filter;
|
|
186
|
+
|
|
187
|
+
// Fade non-matching dots instead of hiding
|
|
188
|
+
link.style.opacity = visible ? '1' : '0.1';
|
|
189
|
+
link.style.pointerEvents = visible ? 'auto' : 'none';
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Filter legend rows
|
|
193
|
+
legendRows.forEach((row) => {
|
|
194
|
+
const segment = row.dataset.segment ?? '';
|
|
195
|
+
const ring = row.dataset.ring ?? '';
|
|
196
|
+
|
|
197
|
+
let visible = true;
|
|
198
|
+
if (type === 'segment') visible = segment === filter;
|
|
199
|
+
else if (type === 'ring') visible = ring === filter;
|
|
200
|
+
|
|
201
|
+
row.style.opacity = visible ? '1' : '0.15';
|
|
202
|
+
row.style.pointerEvents = visible ? 'auto' : 'none';
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
</script>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection, render, type CollectionEntry } from 'astro:content';
|
|
3
|
+
import Base from '../../layouts/Base.astro';
|
|
4
|
+
import TechnologyCard from '../../components/TechnologyCard.astro';
|
|
5
|
+
import { RINGS, RING_LABELS } from '../../lib/radar';
|
|
6
|
+
import type { GetStaticPaths } from 'astro';
|
|
7
|
+
|
|
8
|
+
const base = import.meta.env.BASE_URL.endsWith('/') ? import.meta.env.BASE_URL : import.meta.env.BASE_URL + '/';
|
|
9
|
+
|
|
10
|
+
export const getStaticPaths: GetStaticPaths = async () => {
|
|
11
|
+
const segmentEntries = await getCollection('segments');
|
|
12
|
+
return segmentEntries.map((entry) => {
|
|
13
|
+
const slug = entry.id.replace(/\/index$/, '').replace(/^index$/, entry.id);
|
|
14
|
+
return {
|
|
15
|
+
params: { segment: slug },
|
|
16
|
+
props: { entry },
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const { entry } = Astro.props as { entry: CollectionEntry<'segments'> };
|
|
22
|
+
const segmentSlug = Astro.params.segment;
|
|
23
|
+
|
|
24
|
+
const { Content } = await render(entry);
|
|
25
|
+
|
|
26
|
+
const technologyEntries = await getCollection('technologies');
|
|
27
|
+
const segTechnologies = technologyEntries
|
|
28
|
+
.filter((t) => t.id.startsWith(`${segmentSlug}/`))
|
|
29
|
+
.map((t) => ({
|
|
30
|
+
title: t.data.title,
|
|
31
|
+
ring: t.data.ring,
|
|
32
|
+
moved: t.data.moved,
|
|
33
|
+
href: `${base}technology/${t.id}/`,
|
|
34
|
+
}));
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
<Base title={`${entry.data.title} — Tech Radar`}>
|
|
38
|
+
<nav class="mb-6 text-sm" style="color: var(--radar-text-faint);">
|
|
39
|
+
<a href={base} class="breadcrumb-link">Tech Radar</a>
|
|
40
|
+
<span class="mx-2">/</span>
|
|
41
|
+
<span style="color: var(--radar-text-secondary);">{entry.data.title}</span>
|
|
42
|
+
</nav>
|
|
43
|
+
|
|
44
|
+
<div class="mb-8">
|
|
45
|
+
<h1 class="text-3xl font-bold flex items-center gap-3" style="color: var(--radar-text);">
|
|
46
|
+
<span class="inline-block h-4 w-4 rounded-full" style={`background-color: ${entry.data.color}`}></span>
|
|
47
|
+
{entry.data.title}
|
|
48
|
+
</h1>
|
|
49
|
+
<div class="prose mt-4 max-w-none">
|
|
50
|
+
<Content />
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
{RINGS.map((ring) => {
|
|
55
|
+
const ringTechs = segTechnologies.filter((t) => t.ring === ring);
|
|
56
|
+
if (ringTechs.length === 0) return null;
|
|
57
|
+
return (
|
|
58
|
+
<section class="mb-6">
|
|
59
|
+
<h2 class="mb-3 text-lg font-semibold" style="color: var(--radar-text-secondary);">{RING_LABELS[ring]}</h2>
|
|
60
|
+
<div class="grid gap-2 sm:grid-cols-2">
|
|
61
|
+
{ringTechs.map((tech) => (
|
|
62
|
+
<TechnologyCard
|
|
63
|
+
title={tech.title}
|
|
64
|
+
ring={tech.ring}
|
|
65
|
+
moved={tech.moved}
|
|
66
|
+
href={tech.href}
|
|
67
|
+
/>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
</section>
|
|
71
|
+
);
|
|
72
|
+
})}
|
|
73
|
+
</Base>
|
|
74
|
+
|
|
75
|
+
<style>
|
|
76
|
+
.breadcrumb-link {
|
|
77
|
+
color: var(--radar-text-faint);
|
|
78
|
+
transition: color 0.15s;
|
|
79
|
+
}
|
|
80
|
+
.breadcrumb-link:hover {
|
|
81
|
+
color: var(--radar-text-secondary);
|
|
82
|
+
}
|
|
83
|
+
</style>
|