返回 html-video
nyt-chart.html
1 <template id="nyt-chart-template">
2 <div data-composition-id="nyt-chart" data-width="1920" data-height="1080" data-duration="15">
3 <!-- Google Fonts Import -->
4 <link rel="preconnect" href="https://fonts.googleapis.com" />
5 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
6 <link
7 href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:wght@400;700&family=Libre+Franklin:wght@300;400;600&display=block"
8 rel="stylesheet"
9 />
10
11 <div class="chart-container">
12 <!-- Header Section -->
13 <div class="header">
14 <h1 class="headline">Monthly Revenue vs. Conversion Rate</h1>
15 <p class="subtitle">Jan–Jun 2024, in thousands</p>
16 <div class="key">
17 <div class="key-item">
18 <div class="key-box revenue-box"></div>
19 <span class="key-text">Revenue</span>
20 </div>
21 <div class="key-item">
22 <div class="key-line conversion-line"></div>
23 <span class="key-text">Conversion Rate</span>
24 </div>
25 </div>
26 </div>
27
28 <!-- SVG Chart Area -->
29 <svg class="chart-svg" viewBox="0 0 1600 700" preserveAspectRatio="xMidYMid meet">
30 <!-- Gridlines Group -->
31 <g class="gridlines"></g>
32
33 <!-- X-Axis Labels -->
34 <g class="x-axis-labels"></g>
35
36 <!-- Bars Group (Revenue) -->
37 <g class="bars-group"></g>
38
39 <!-- Bar Labels Group -->
40 <g class="bar-labels-group"></g>
41
42 <!-- Line Group (Conversion Rate) -->
43 <g class="line-group">
44 <path class="conversion-path" fill="none" stroke="#326FA8" stroke-width="1.5" />
45 <circle class="line-dot" r="5" fill="#326FA8" style="opacity: 0" />
46 </g>
47
48 <!-- Line Labels Group -->
49 <g class="line-labels-group"></g>
50
51 <!-- Baseline -->
52 <line x1="100" y1="600" x2="1500" y2="600" stroke="black" stroke-width="1" />
53 </svg>
54
55 <!-- Source Line -->
56 <div class="source">Source: Internal analytics</div>
57 </div>
58
59 <style>
60 [data-composition-id="nyt-chart"] {
61 width: 1920px;
62 height: 1080px;
63 background-color: #faf9f6;
64 font-family: "Libre Franklin", sans-serif;
65 color: #333;
66 display: flex;
67 justify-content: center;
68 align-items: center;
69 overflow: hidden;
70 }
71
72 [data-composition-id="nyt-chart"] .chart-container {
73 width: 1600px;
74 height: 900px;
75 display: flex;
76 flex-direction: column;
77 position: relative;
78 transform: scale(1.15);
79 transform-origin: center center;
80 }
81
82 [data-composition-id="nyt-chart"] .header {
83 margin-bottom: 40px;
84 text-align: left;
85 }
86
87 [data-composition-id="nyt-chart"] .headline {
88 font-family: "Libre Baskerville", serif;
89 font-size: 42px;
90 margin: 0;
91 padding: 0;
92 clip-path: inset(0 100% 0 0);
93 white-space: nowrap;
94 }
95
96 [data-composition-id="nyt-chart"] .subtitle {
97 font-size: 16px;
98 color: #d4d4d4;
99 margin: 8px 0 16px 0;
100 clip-path: inset(0 100% 0 0);
101 white-space: nowrap;
102 }
103
104 [data-composition-id="nyt-chart"] .key {
105 display: flex;
106 gap: 24px;
107 opacity: 0;
108 }
109
110 [data-composition-id="nyt-chart"] .key-item {
111 display: flex;
112 align-items: center;
113 gap: 8px;
114 }
115
116 [data-composition-id="nyt-chart"] .key-box {
117 width: 10px;
118 height: 10px;
119 background-color: #5c5c5c;
120 }
121
122 [data-composition-id="nyt-chart"] .key-line {
123 width: 16px;
124 height: 1.5px;
125 background-color: #326fa8;
126 }
127
128 [data-composition-id="nyt-chart"] .key-text {
129 font-size: 13px;
130 color: #666;
131 }
132
133 [data-composition-id="nyt-chart"] .chart-svg {
134 width: 100%;
135 height: 600px;
136 overflow: visible;
137 }
138
139 [data-composition-id="nyt-chart"] .source {
140 position: absolute;
141 bottom: 0;
142 left: 0;
143 font-size: 11px;
144 color: #999;
145 opacity: 0;
146 }
147
148 [data-composition-id="nyt-chart"] .grid-line {
149 stroke: #e8e8e8;
150 stroke-width: 0.5px;
151 opacity: 0;
152 }
153
154 [data-composition-id="nyt-chart"] .axis-label {
155 font-size: 14px;
156 fill: #666;
157 text-anchor: middle;
158 }
159
160 [data-composition-id="nyt-chart"] .bar {
161 fill: #5c5c5c;
162 }
163
164 [data-composition-id="nyt-chart"] .bar-label {
165 font-size: 14px;
166 fill: #333;
167 text-anchor: middle;
168 opacity: 0;
169 }
170
171 [data-composition-id="nyt-chart"] .line-label {
172 font-size: 14px;
173 fill: #326fa8;
174 font-weight: 600;
175 text-anchor: middle;
176 opacity: 0;
177 }
178 </style>
179
180 <script>
181 (function initChart() {
182 // Wait for DOM — the runtime may not have cloned the template content yet
183 const svg = document.querySelector(`[data-composition-id="nyt-chart"] .chart-svg`);
184 if (!svg) {
185 setTimeout(initChart, 50);
186 return;
187 }
188 const tl = gsap.timeline({ paused: true });
189
190 // Data
191 const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"];
192 const revenueData = [8, 12, 15, 11, 18, 22]; // in thousands
193 const conversionData = [2.1, 2.8, 3.2, 2.9, 3.8, 4.2]; // in percentage
194
195 // Chart Dimensions
196 const chartWidth = 1400;
197 const chartHeight = 500;
198 const startX = 100;
199 const startY = 600;
200 const barWidth = 80;
201 const spacing = (chartWidth - barWidth * months.length) / (months.length + 1);
202
203 // Scales
204 const maxRevenue = 25;
205 const maxConversion = 5;
206
207 const getBarY = (val) => startY - (val / maxRevenue) * chartHeight;
208 const getLineY = (val) => startY - (val / maxConversion) * chartHeight;
209 const getX = (i) => startX + spacing + i * (barWidth + spacing) + barWidth / 2;
210
211 // 1. Setup Elements (svg already found above)
212 const gridGroup = svg.querySelector(".gridlines");
213 const xAxisGroup = svg.querySelector(".x-axis-labels");
214 const barsGroup = svg.querySelector(".bars-group");
215 const barLabelsGroup = svg.querySelector(".bar-labels-group");
216 const lineLabelsGroup = svg.querySelector(".line-labels-group");
217 const conversionPath = svg.querySelector(".conversion-path");
218 const lineDot = svg.querySelector(".line-dot");
219
220 // Create Gridlines (5 lines)
221 for (let i = 1; i <= 5; i++) {
222 const y = startY - i * (chartHeight / 5);
223 const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
224 line.setAttribute("x1", startX);
225 line.setAttribute("y1", y);
226 line.setAttribute("x2", startX + chartWidth);
227 line.setAttribute("y2", y);
228 line.setAttribute("class", "grid-line");
229 gridGroup.appendChild(line);
230 }
231
232 // Create X-Axis Labels & Bars & Bar Labels
233 const linePoints = [];
234 months.forEach((month, i) => {
235 const x = getX(i);
236
237 // X-Axis Label
238 const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
239 label.setAttribute("x", x);
240 label.setAttribute("y", startY + 30);
241 label.setAttribute("class", "axis-label");
242 label.textContent = month;
243 xAxisGroup.appendChild(label);
244
245 // Bar
246 const barHeight = (revenueData[i] / maxRevenue) * chartHeight;
247 const bar = document.createElementNS("http://www.w3.org/2000/svg", "rect");
248 bar.setAttribute("x", x - barWidth / 2);
249 bar.setAttribute("y", startY);
250 bar.setAttribute("width", barWidth);
251 bar.setAttribute("height", 0);
252 bar.setAttribute("class", "bar");
253 barsGroup.appendChild(bar);
254
255 // Bar Label
256 const barLabel = document.createElementNS("http://www.w3.org/2000/svg", "text");
257 barLabel.setAttribute("x", x);
258 barLabel.setAttribute("y", getBarY(revenueData[i]) - 10);
259 barLabel.setAttribute("class", "bar-label");
260 barLabel.textContent = `$${revenueData[i]}K`;
261 barLabelsGroup.appendChild(barLabel);
262
263 // Line Point
264 linePoints.push(`${x},${getLineY(conversionData[i])}`);
265
266 // Line Label
267 const lineLabel = document.createElementNS("http://www.w3.org/2000/svg", "text");
268 lineLabel.setAttribute("x", x);
269 lineLabel.setAttribute("y", getLineY(conversionData[i]) - 15);
270 lineLabel.setAttribute("class", "line-label");
271 lineLabel.textContent = `${conversionData[i]}%`;
272 lineLabelsGroup.appendChild(lineLabel);
273 });
274
275 // Set Path Data
276 conversionPath.setAttribute("d", `M ${linePoints.join(" L ")}`);
277 const pathLength = conversionPath.getTotalLength();
278 conversionPath.style.strokeDasharray = pathLength;
279 conversionPath.style.strokeDashoffset = pathLength;
280
281 // --- ANIMATION TIMELINE ---
282
283 // 2. Headline & Subtitle (Draw on left-to-right)
284 tl.to(
285 `[data-composition-id="nyt-chart"] .headline`,
286 {
287 clipPath: "inset(0 0% 0 0)",
288 duration: 1.2,
289 ease: "power1.inOut",
290 },
291 0,
292 );
293
294 tl.to(
295 `[data-composition-id="nyt-chart"] .subtitle`,
296 {
297 clipPath: "inset(0 0% 0 0)",
298 duration: 1.2,
299 ease: "power1.inOut",
300 },
301 0.2,
302 );
303
304 // 3. Inline Key
305 tl.to(
306 `[data-composition-id="nyt-chart"] .key`,
307 {
308 opacity: 1,
309 duration: 0.4,
310 ease: "power2.out",
311 },
312 1.0,
313 );
314
315 // 4. Gridlines (Stagger bottom to top)
316 tl.to(
317 `[data-composition-id="nyt-chart"] .grid-line`,
318 {
319 opacity: 1,
320 duration: 0.5,
321 stagger: 0.25,
322 ease: "power2.out",
323 },
324 0.5,
325 );
326
327 // 5. Bars (Revenue)
328 const bars = barsGroup.querySelectorAll(".bar");
329 const barLabels = barLabelsGroup.querySelectorAll(".bar-label");
330
331 bars.forEach((bar, i) => {
332 const barHeight = (revenueData[i] / maxRevenue) * chartHeight;
333 const startTime = 1.5 + i * 0.5; // 0.8s duration, 0.3s overlap (0.8 - 0.3 = 0.5 step)
334
335 tl.to(
336 bar,
337 {
338 attr: {
339 height: barHeight,
340 y: startY - barHeight,
341 },
342 duration: 0.8,
343 ease: "power2.out",
344 },
345 startTime,
346 );
347
348 // 6. Bar Labels
349 tl.to(
350 barLabels[i],
351 {
352 opacity: 1,
353 duration: 0.3,
354 ease: "power2.out",
355 },
356 startTime + 0.8,
357 );
358 });
359
360 // 7. Line (Conversion Rate)
361 // Starts 0.5s after the last bar begins
362 const lastBarStartTime = 1.5 + (months.length - 1) * 0.5;
363 const lineStartTime = lastBarStartTime + 0.5;
364
365 tl.to(
366 conversionPath,
367 {
368 strokeDashoffset: 0,
369 duration: 3,
370 ease: "none",
371 },
372 lineStartTime,
373 );
374
375 // 8. Line Dot & 9. Line Labels
376 // We need to sync the dot and labels with the path drawing
377 tl.set(
378 lineDot,
379 { opacity: 1, attr: { cx: getX(0), cy: getLineY(conversionData[0]) } },
380 lineStartTime,
381 );
382
383 const lineLabels = lineLabelsGroup.querySelectorAll(".line-label");
384 months.forEach((_, i) => {
385 if (i > 0) {
386 const x = getX(i);
387 const y = getLineY(conversionData[i]);
388 tl.to(
389 lineDot,
390 {
391 attr: {
392 cx: x,
393 cy: y,
394 },
395 duration: 3 / (months.length - 1),
396 ease: "none",
397 },
398 lineStartTime + ((i - 1) / (months.length - 1)) * 3,
399 );
400 }
401
402 const labelTime = lineStartTime + (i / (months.length - 1)) * 3 + 0.2;
403
404 // Set initial text to 0.0%
405 lineLabels[i].textContent = "0.0%";
406
407 // Fade in
408 tl.to(
409 lineLabels[i],
410 {
411 opacity: 1,
412 duration: 0.1,
413 ease: "power2.out",
414 },
415 labelTime,
416 );
417
418 // Animate value
419 const dummy = { val: 0 };
420 tl.to(
421 dummy,
422 {
423 val: conversionData[i],
424 duration: 0.8,
425 ease: "power2.out",
426 onUpdate: () => {
427 lineLabels[i].textContent = dummy.val.toFixed(1) + "%";
428 },
429 },
430 labelTime,
431 );
432 });
433
434 // 11. Source Line
435 tl.to(
436 `[data-composition-id="nyt-chart"] .source`,
437 {
438 opacity: 1,
439 duration: 0.5,
440 ease: "power2.out",
441 },
442 ">+0.5",
443 );
444
445 // Register timeline
446 window.__timelines = window.__timelines || {};
447 window.__timelines["nyt-chart"] = tl;
448 })();
449 </script>
450 </div>
451 </template>
452
452 lines HTML