blackopsrepl/portfolio-optimization-python
1
1/**
2 * Portfolio Optimization Quickstart - Frontend Application
3 */
4
5// =============================================================================
6// Global State
7// =============================================================================
8
9let autoRefreshIntervalId = null;
10let demoDataId = "SMALL";
11let scheduleId = null;
12let loadedPlan = null;
13
14// Chart instances
15let sectorChart = null;
16let returnsChart = null;
17
18// Sorting state for stock table
19let sortColumn = 'predictedReturn';
20let sortDirection = 'desc';
21
22// Sector colors (consistent across charts and badges)
23const SECTOR_COLORS = {
24 'Technology': '#3b82f6',
25 'Healthcare': '#10b981',
26 'Finance': '#eab308',
27 'Energy': '#ef4444',
28 'Consumer': '#a855f7'
29};
30
31// =============================================================================
32// Initialization
33// =============================================================================
34
35$(document).ready(function() {
36 // Initialize header/footer with nav tabs
37 replaceQuickstartSolverForgeAutoHeaderFooter();
38
39 // Initialize Bootstrap tooltips
40 const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
41 [...tooltipTriggerList].map(el => new bootstrap.Tooltip(el));
42
43 // Load initial data
44 loadDemoData();
45
46 // Event handlers
47 $("#solveButton").click(solve);
48 $("#stopSolvingButton").click(stopSolving);
49 $("#analyzeButton").click(analyze);
50 $("#dataSelector").change(function() {
51 demoDataId = $(this).val();
52 loadDemoData();
53 });
54 $("#showSelectedOnly").change(renderAllStocksTable);
55
56 // Sortable table headers
57 $("#allStocksTable").closest("table").find("th[data-sort]").click(function() {
58 const column = $(this).data("sort");
59 if (sortColumn === column) {
60 sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
61 } else {
62 sortColumn = column;
63 sortDirection = 'desc';
64 }
65 updateSortIndicators();
66 renderAllStocksTable();
67 });
68
69 // Initialize advanced configuration if available (from config.js)
70 if (typeof initAdvancedConfig === 'function') {
71 initAdvancedConfig();
72 }
73});
74
75// =============================================================================
76// jQuery AJAX Extensions
77// =============================================================================
78
79$.ajaxSetup({
80 contentType: "application/json",
81 accepts: {
82 json: "application/json"
83 }
84});
85
86$.put = function(url, data) {
87 return $.ajax({
88 url: url,
89 type: "PUT",
90 data: JSON.stringify(data),
91 contentType: "application/json"
92 });
93};
94
95$.delete = function(url) {
96 return $.ajax({
97 url: url,
98 type: "DELETE"
99 });
100};
101
102// =============================================================================
103// Data Loading
104// =============================================================================
105
106function loadDemoData() {
107 $.getJSON("/demo-data/" + demoDataId)
108 .done(function(data) {
109 loadedPlan = data;
110 scheduleId = null;
111 // Apply advanced config if available (from config.js)
112 if (typeof applyConfigToLoadedPlan === 'function') {
113 applyConfigToLoadedPlan();
114 }
115 refreshUI();
116 updateScore("Score: ?");
117 })
118 .fail(function(xhr) {
119 showError("Failed to load demo data", xhr);
120 });
121}
122
123// =============================================================================
124// Solving
125// =============================================================================
126
127function solve() {
128 if (!loadedPlan) {
129 showSimpleError("Please load demo data first");
130 return;
131 }
132
133 $.ajax({
134 url: "/portfolios",
135 type: "POST",
136 data: JSON.stringify(loadedPlan),
137 contentType: "application/json"
138 })
139 .done(function(data) {
140 scheduleId = data;
141 refreshSolvingButtons(true);
142 })
143 .fail(function(xhr) {
144 showError("Failed to start solver", xhr);
145 });
146}
147
148function stopSolving() {
149 if (!scheduleId) return;
150
151 $.delete("/portfolios/" + scheduleId)
152 .done(function(data) {
153 loadedPlan = data;
154 refreshSolvingButtons(false);
155 refreshUI();
156 })
157 .fail(function(xhr) {
158 showError("Failed to stop solver", xhr);
159 });
160}
161
162function refreshSolvingButtons(solving) {
163 if (solving) {
164 $("#solveButton").hide();
165 $("#stopSolvingButton").show();
166 $("#solvingSpinner").addClass("active");
167 $(".kpi-value").addClass("solving-pulse");
168
169 if (autoRefreshIntervalId == null) {
170 autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
171 }
172 } else {
173 $("#solveButton").show();
174 $("#stopSolvingButton").hide();
175 $("#solvingSpinner").removeClass("active");
176 $(".kpi-value").removeClass("solving-pulse");
177
178 if (autoRefreshIntervalId != null) {
179 clearInterval(autoRefreshIntervalId);
180 autoRefreshIntervalId = null;
181 }
182 }
183}
184
185function refreshSchedule() {
186 if (!scheduleId) return;
187
188 $.getJSON("/portfolios/" + scheduleId)
189 .done(function(data) {
190 loadedPlan = data;
191 refreshUI();
192
193 // Check if solver is done
194 if (data.solverStatus === "NOT_SOLVING") {
195 refreshSolvingButtons(false);
196 }
197 })
198 .fail(function(xhr) {
199 showError("Failed to refresh solution", xhr);
200 refreshSolvingButtons(false);
201 });
202}
203
204// =============================================================================
205// Score Analysis
206// =============================================================================
207
208function analyze() {
209 if (!loadedPlan) {
210 showSimpleError("Please load demo data first");
211 return;
212 }
213
214 $.put("/portfolios/analyze", loadedPlan)
215 .done(function(data) {
216 showConstraintAnalysis(data);
217 })
218 .fail(function(xhr) {
219 showError("Failed to analyze constraints", xhr);
220 });
221}
222
223function showConstraintAnalysis(analysis) {
224 const tbody = $("#weightModalContent tbody");
225 tbody.empty();
226
227 // Update modal label with score
228 const score = loadedPlan.score || "?";
229 $("#weightModalLabel").text(score);
230
231 // Sort constraints: hard first, then by score impact
232 const constraints = analysis.constraints || [];
233 constraints.sort((a, b) => {
234 if (a.type !== b.type) {
235 return a.type === "HARD" ? -1 : 1;
236 }
237 return Math.abs(b.score) - Math.abs(a.score);
238 });
239
240 constraints.forEach(function(c) {
241 const icon = c.score === 0
242 ? '<i class="fas fa-check-circle text-success"></i>'
243 : '<i class="fas fa-exclamation-triangle text-danger"></i>';
244
245 const typeClass = c.type === "HARD" ? "text-danger" : "text-warning";
246 const scoreClass = c.score === 0 ? "text-success" : "text-danger";
247
248 tbody.append(`
249 <tr>
250 <td>${icon}</td>
251 <td>${c.name}</td>
252 <td><span class="${typeClass}">${c.type}</span></td>
253 <td>${c.weight || '-'}</td>
254 <td>${c.matchCount || 0}</td>
255 <td class="${scoreClass}">${c.score}</td>
256 </tr>
257 `);
258 });
259
260 // Show modal
261 const modal = new bootstrap.Modal(document.getElementById('weightModal'));
262 modal.show();
263}
264
265// =============================================================================
266// UI Refresh
267// =============================================================================
268
269function refreshUI() {
270 updateKPIs();
271 updateScore();
272 updateCharts();
273 renderSelectedStocksTable();
274 renderAllStocksTable();
275}
276
277function updateKPIs() {
278 if (!loadedPlan || !loadedPlan.stocks) {
279 resetKPIs();
280 return;
281 }
282
283 const stocks = loadedPlan.stocks;
284 const selected = stocks.filter(s => s.selected);
285 const selectedCount = selected.length;
286
287 // Get target from plan, config module, or default to 20
288 let targetCount = loadedPlan.targetPositionCount || 20;
289 if (typeof getTargetStockCount === 'function') {
290 targetCount = getTargetStockCount();
291 }
292
293 // Selected stocks count (show target)
294 const selectedEl = $("#selectedCount");
295 selectedEl.text(selectedCount + "/" + targetCount);
296 if (selectedCount === targetCount) {
297 selectedEl.removeClass("text-warning").addClass("text-success");
298 } else if (selectedCount > 0) {
299 selectedEl.removeClass("text-success").addClass("text-warning");
300 } else {
301 selectedEl.removeClass("text-success text-warning");
302 }
303 $("#selectedBadge").text(selectedCount + " selected");
304
305 // Stock count badge
306 $("#stockCountBadge").text(stocks.length + " stocks");
307
308 // Use metrics from backend if available
309 if (loadedPlan.metrics) {
310 updateKPIsFromMetrics(loadedPlan.metrics);
311 } else {
312 // Fallback: calculate locally
313 updateKPIsLocally(selected, selectedCount);
314 }
315}
316
317function resetKPIs() {
318 $("#selectedCount").text("0/20");
319 $("#expectedReturn").text("0.00%").removeClass("positive negative");
320 $("#sectorCount").text("0");
321 $("#diversificationScore").text("0%").removeClass("text-success text-warning text-danger");
322 $("#maxSectorExposure").text("0%");
323 $("#returnVolatility").text("0.00%");
324 $("#sharpeProxy").text("0.00");
325 $("#herfindahlIndex").text("0.000");
326 $("#selectedBadge").text("0 selected");
327}
328
329function updateKPIsFromMetrics(metrics) {
330 // Expected return
331 const returnEl = $("#expectedReturn");
332 returnEl.text((metrics.expectedReturn * 100).toFixed(2) + "%");
333 returnEl.removeClass("positive negative");
334 returnEl.addClass(metrics.expectedReturn >= 0 ? "positive" : "negative");
335
336 // Sector count
337 $("#sectorCount").text(metrics.sectorCount);
338
339 // Diversification score (0-100%)
340 const divScore = (metrics.diversificationScore * 100).toFixed(0);
341 const divEl = $("#diversificationScore");
342 divEl.text(divScore + "%");
343 divEl.removeClass("text-success text-warning text-danger");
344 if (metrics.diversificationScore >= 0.7) {
345 divEl.addClass("text-success");
346 } else if (metrics.diversificationScore >= 0.5) {
347 divEl.addClass("text-warning");
348 } else {
349 divEl.addClass("text-danger");
350 }
351
352 // Max sector exposure
353 $("#maxSectorExposure").text((metrics.maxSectorExposure * 100).toFixed(1) + "%");
354
355 // Return volatility
356 $("#returnVolatility").text((metrics.returnVolatility * 100).toFixed(2) + "%");
357
358 // Sharpe proxy
359 const sharpeEl = $("#sharpeProxy");
360 sharpeEl.text(metrics.sharpeProxy.toFixed(2));
361 sharpeEl.removeClass("text-success text-warning text-danger");
362 if (metrics.sharpeProxy >= 1.0) {
363 sharpeEl.addClass("text-success");
364 } else if (metrics.sharpeProxy >= 0.5) {
365 sharpeEl.addClass("text-warning");
366 }
367
368 // HHI (Herfindahl-Hirschman Index)
369 const hhiEl = $("#herfindahlIndex");
370 hhiEl.text(metrics.herfindahlIndex.toFixed(3));
371 hhiEl.removeClass("text-success text-warning text-danger");
372 if (metrics.herfindahlIndex < 0.15) {
373 hhiEl.addClass("text-success");
374 } else if (metrics.herfindahlIndex < 0.25) {
375 hhiEl.addClass("text-warning");
376 } else {
377 hhiEl.addClass("text-danger");
378 }
379}
380
381function updateKPIsLocally(selected, selectedCount) {
382 // Expected return (weighted average)
383 const expectedReturn = selectedCount > 0
384 ? selected.reduce((sum, s) => sum + s.predictedReturn, 0) / selectedCount
385 : 0;
386 const returnEl = $("#expectedReturn");
387 returnEl.text((expectedReturn * 100).toFixed(2) + "%");
388 returnEl.removeClass("positive negative");
389 returnEl.addClass(expectedReturn >= 0 ? "positive" : "negative");
390
391 // Sector count
392 const sectors = new Set(selected.map(s => s.sector));
393 $("#sectorCount").text(sectors.size);
394
395 if (selectedCount > 0) {
396 // Calculate sector weights and HHI locally
397 const sectorCounts = {};
398 selected.forEach(s => {
399 sectorCounts[s.sector] = (sectorCounts[s.sector] || 0) + 1;
400 });
401
402 const sectorWeights = Object.values(sectorCounts).map(c => c / selectedCount);
403 const hhi = sectorWeights.reduce((sum, w) => sum + w * w, 0);
404 const divScore = ((1 - hhi) * 100).toFixed(0);
405 const maxSector = Math.max(...sectorWeights) * 100;
406
407 $("#diversificationScore").text(divScore + "%");
408 $("#maxSectorExposure").text(maxSector.toFixed(1) + "%");
409 $("#herfindahlIndex").text(hhi.toFixed(3));
410
411 // Calculate volatility locally
412 const returns = selected.map(s => s.predictedReturn);
413 const meanReturn = returns.reduce((a, b) => a + b, 0) / returns.length;
414 const variance = returns.reduce((sum, r) => sum + Math.pow(r - meanReturn, 2), 0) / returns.length;
415 const volatility = Math.sqrt(variance);
416
417 $("#returnVolatility").text((volatility * 100).toFixed(2) + "%");
418 $("#sharpeProxy").text(volatility > 0 ? (expectedReturn / volatility).toFixed(2) : "0.00");
419 } else {
420 $("#diversificationScore").text("0%");
421 $("#maxSectorExposure").text("0%");
422 $("#returnVolatility").text("0.00%");
423 $("#sharpeProxy").text("0.00");
424 $("#herfindahlIndex").text("0.000");
425 }
426}
427
428function updateScore() {
429 if (!loadedPlan || !loadedPlan.score) {
430 $("#score").text("Score: ?");
431 return;
432 }
433
434 const score = loadedPlan.score;
435 const match = score.match(/(-?\d+)hard\/(-?\d+)soft/);
436
437 if (match) {
438 const hard = parseInt(match[1]);
439 const soft = parseInt(match[2]);
440 const isFeasible = hard === 0;
441
442 const scoreHtml = `
443 <span class="score-badge ${isFeasible ? 'score-feasible' : 'score-infeasible'}">
444 ${hard}hard
445 </span>
446 <span class="score-badge score-soft ms-1">
447 ${soft}soft
448 </span>
449 `;
450 $("#score").html(scoreHtml);
451 } else {
452 $("#score").text("Score: " + score);
453 }
454}
455
456// =============================================================================
457// Charts
458// =============================================================================
459
460function updateCharts() {
461 updateSectorChart();
462 updateReturnsChart();
463}
464
465function updateSectorChart() {
466 const ctx = document.getElementById('sectorChart');
467 if (!ctx) return;
468
469 if (!loadedPlan || !loadedPlan.stocks) {
470 if (sectorChart) {
471 sectorChart.destroy();
472 sectorChart = null;
473 }
474 return;
475 }
476
477 const selected = loadedPlan.stocks.filter(s => s.selected);
478
479 if (selected.length === 0) {
480 if (sectorChart) {
481 sectorChart.destroy();
482 sectorChart = null;
483 }
484 return;
485 }
486
487 // Calculate sector counts
488 const sectorCounts = {};
489 selected.forEach(s => {
490 sectorCounts[s.sector] = (sectorCounts[s.sector] || 0) + 1;
491 });
492
493 const labels = Object.keys(sectorCounts);
494 const data = labels.map(s => (sectorCounts[s] / selected.length) * 100);
495 const colors = labels.map(s => SECTOR_COLORS[s] || '#64748b');
496
497 if (sectorChart) {
498 sectorChart.destroy();
499 }
500
501 sectorChart = new Chart(ctx, {
502 type: 'doughnut',
503 data: {
504 labels: labels,
505 datasets: [{
506 data: data,
507 backgroundColor: colors,
508 borderWidth: 3,
509 borderColor: '#fff',
510 hoverOffset: 8
511 }]
512 },
513 options: {
514 responsive: true,
515 maintainAspectRatio: false,
516 cutout: '60%',
517 plugins: {
518 legend: {
519 position: 'right',
520 labels: {
521 padding: 16,
522 usePointStyle: true,
523 pointStyle: 'circle'
524 }
525 },
526 tooltip: {
527 callbacks: {
528 label: function(context) {
529 const value = context.raw.toFixed(1);
530 const count = sectorCounts[context.label];
531 return `${context.label}: ${value}% (${count} stocks)`;
532 }
533 }
534 }
535 }
536 }
537 });
538}
539
540function updateReturnsChart() {
541 const ctx = document.getElementById('returnsChart');
542 if (!ctx) return;
543
544 if (!loadedPlan || !loadedPlan.stocks) {
545 if (returnsChart) {
546 returnsChart.destroy();
547 returnsChart = null;
548 }
549 return;
550 }
551
552 const selected = loadedPlan.stocks.filter(s => s.selected);
553
554 if (selected.length === 0) {
555 if (returnsChart) {
556 returnsChart.destroy();
557 returnsChart = null;
558 }
559 return;
560 }
561
562 // Sort by return and take top 10
563 const top10 = [...selected]
564 .sort((a, b) => b.predictedReturn - a.predictedReturn)
565 .slice(0, 10);
566
567 const labels = top10.map(s => s.stockId);
568 const data = top10.map(s => s.predictedReturn * 100);
569 const colors = top10.map(s => SECTOR_COLORS[s.sector] || '#64748b');
570
571 if (returnsChart) {
572 returnsChart.destroy();
573 }
574
575 returnsChart = new Chart(ctx, {
576 type: 'bar',
577 data: {
578 labels: labels,
579 datasets: [{
580 label: 'Predicted Return (%)',
581 data: data,
582 backgroundColor: colors,
583 borderRadius: 6,
584 borderSkipped: false
585 }]
586 },
587 options: {
588 responsive: true,
589 maintainAspectRatio: false,
590 indexAxis: 'y',
591 plugins: {
592 legend: {
593 display: false
594 },
595 tooltip: {
596 callbacks: {
597 label: function(context) {
598 const stock = top10[context.dataIndex];
599 return `${stock.stockName}: ${context.raw.toFixed(2)}%`;
600 }
601 }
602 }
603 },
604 scales: {
605 x: {
606 beginAtZero: true,
607 grid: {
608 display: true,
609 color: 'rgba(0,0,0,0.05)'
610 },
611 title: {
612 display: true,
613 text: 'Predicted Return (%)',
614 color: '#64748b'
615 }
616 },
617 y: {
618 grid: {
619 display: false
620 }
621 }
622 }
623 }
624 });
625}
626
627// =============================================================================
628// Tables
629// =============================================================================
630
631function renderSelectedStocksTable() {
632 const tbody = $("#selectedStocksTable");
633
634 if (!loadedPlan || !loadedPlan.stocks) {
635 tbody.html(`
636 <tr>
637 <td colspan="5" class="text-center text-muted py-4">
638 <i class="fas fa-info-circle me-2"></i>No data loaded
639 </td>
640 </tr>
641 `);
642 return;
643 }
644
645 const selected = loadedPlan.stocks
646 .filter(s => s.selected)
647 .sort((a, b) => b.predictedReturn - a.predictedReturn);
648
649 if (selected.length === 0) {
650 tbody.html(`
651 <tr>
652 <td colspan="5" class="text-center text-muted py-4">
653 <i class="fas fa-info-circle me-2"></i>No stocks selected yet
654 </td>
655 </tr>
656 `);
657 return;
658 }
659
660 const weightPerStock = 100 / selected.length;
661
662 tbody.html(selected.map(stock => {
663 const returnClass = stock.predictedReturn >= 0 ? 'return-positive' : 'return-negative';
664 const returnSign = stock.predictedReturn >= 0 ? '+' : '';
665
666 return `
667 <tr class="stock-selected">
668 <td><strong>${stock.stockId}</strong></td>
669 <td>${stock.stockName}</td>
670 <td><span class="sector-badge sector-${stock.sector}">${stock.sector}</span></td>
671 <td class="${returnClass}">${returnSign}${(stock.predictedReturn * 100).toFixed(2)}%</td>
672 <td>${weightPerStock.toFixed(2)}%</td>
673 </tr>
674 `;
675 }).join(''));
676}
677
678function renderAllStocksTable() {
679 const tbody = $("#allStocksTable");
680 const showSelectedOnly = $("#showSelectedOnly").is(":checked");
681
682 if (!loadedPlan || !loadedPlan.stocks) {
683 tbody.html(`
684 <tr>
685 <td colspan="6" class="text-center text-muted py-4">
686 <i class="fas fa-database me-2"></i>No data loaded
687 </td>
688 </tr>
689 `);
690 return;
691 }
692
693 let stocks = loadedPlan.stocks;
694
695 // Filter if needed
696 if (showSelectedOnly) {
697 stocks = stocks.filter(s => s.selected);
698 }
699
700 // Sort
701 stocks = [...stocks].sort((a, b) => {
702 let aVal = a[sortColumn];
703 let bVal = b[sortColumn];
704
705 // Handle boolean (selected)
706 if (sortColumn === 'selected') {
707 aVal = a.selected ? 1 : 0;
708 bVal = b.selected ? 1 : 0;
709 }
710
711 // Handle strings
712 if (typeof aVal === 'string') {
713 aVal = aVal.toLowerCase();
714 bVal = bVal.toLowerCase();
715 }
716
717 if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
718 if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
719 return 0;
720 });
721
722 if (stocks.length === 0) {
723 tbody.html(`
724 <tr>
725 <td colspan="6" class="text-center text-muted py-4">
726 <i class="fas fa-filter me-2"></i>No stocks match the filter
727 </td>
728 </tr>
729 `);
730 return;
731 }
732
733 // Calculate weight per stock
734 const selectedCount = loadedPlan.stocks.filter(s => s.selected).length;
735 const weightPerStock = selectedCount > 0 ? (100 / selectedCount) : 0;
736
737 tbody.html(stocks.map(stock => {
738 const returnClass = stock.predictedReturn >= 0 ? 'return-positive' : 'return-negative';
739 const returnSign = stock.predictedReturn >= 0 ? '+' : '';
740 const rowClass = stock.selected ? 'stock-selected' : '';
741 const weight = stock.selected ? weightPerStock.toFixed(2) : '0.00';
742
743 return `
744 <tr class="${rowClass}">
745 <td>
746 <span class="badge ${stock.selected ? 'bg-success' : 'bg-secondary'}">
747 ${stock.selected ? 'Yes' : 'No'}
748 </span>
749 </td>
750 <td><strong>${stock.stockId}</strong></td>
751 <td>${stock.stockName}</td>
752 <td><span class="sector-badge sector-${stock.sector}">${stock.sector}</span></td>
753 <td class="${returnClass}">${returnSign}${(stock.predictedReturn * 100).toFixed(2)}%</td>
754 <td>${weight}%</td>
755 </tr>
756 `;
757 }).join(''));
758}
759
760function updateSortIndicators() {
761 const table = $("#allStocksTable").closest("table");
762 table.find("th").removeClass("sorted");
763 table.find("th i").removeClass("fa-sort-up fa-sort-down").addClass("fa-sort");
764
765 const th = table.find(`th[data-sort="${sortColumn}"]`);
766 th.addClass("sorted");
767 th.find("i")
768 .removeClass("fa-sort")
769 .addClass(sortDirection === 'asc' ? 'fa-sort-up' : 'fa-sort-down');
770}
771
772// =============================================================================
773// Header/Footer with Nav Tabs
774// =============================================================================
775
776function replaceQuickstartSolverForgeAutoHeaderFooter() {
777 const solverforgeHeader = $("header#solverforge-auto-header");
778 if (solverforgeHeader != null) {
779 solverforgeHeader.css("background-color", "#ffffff");
780 solverforgeHeader.append(
781 $(`<div class="container-fluid">
782 <nav class="navbar sticky-top navbar-expand-lg shadow-sm mb-3" style="background-color: #ffffff;">
783 <a class="navbar-brand" href="https://www.solverforge.org">
784 <img src="/webjars/solverforge/img/solverforge-horizontal.svg" alt="SolverForge logo" width="300">
785 </a>
786 <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
787 <span class="navbar-toggler-icon"></span>
788 </button>
789 <div class="collapse navbar-collapse" id="navbarNav">
790 <ul class="nav nav-pills ms-4">
791 <li class="nav-item">
792 <button class="nav-link active" data-bs-toggle="pill" data-bs-target="#demo" type="button" style="color: #1f2937;">
793 <i class="fas fa-chart-pie me-1"></i> Demo
794 </button>
795 </li>
796 <li class="nav-item">
797 <button class="nav-link" data-bs-toggle="pill" data-bs-target="#rest" type="button" style="color: #1f2937;">
798 <i class="fas fa-book me-1"></i> Guide
799 </button>
800 </li>
801 <li class="nav-item">
802 <button class="nav-link" data-bs-toggle="pill" data-bs-target="#openapi" type="button" style="color: #1f2937;">
803 <i class="fas fa-code me-1"></i> REST API
804 </button>
805 </li>
806 </ul>
807 </div>
808 </nav>
809 </div>`)
810 );
811 }
812
813 const solverforgeFooter = $("footer#solverforge-auto-footer");
814 if (solverforgeFooter != null) {
815 solverforgeFooter.append(
816 $(`<footer class="bg-black text-white-50">
817 <div class="container">
818 <div class="hstack gap-3 p-4">
819 <div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
820 <div class="vr"></div>
821 <div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
822 <div class="vr"></div>
823 <div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
824 <div class="vr"></div>
825 <div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
826 </div>
827 </div>
828 </footer>`)
829 );
830 }
831}
832
833// =============================================================================
834// Utility Functions
835// =============================================================================
836
837function copyCode(button) {
838 const codeBlock = $(button).closest('.code-block').find('code');
839 const text = codeBlock.text();
840
841 navigator.clipboard.writeText(text).then(function() {
842 const originalHtml = $(button).html();
843 $(button).html('<i class="fas fa-check"></i> Copied!');
844 setTimeout(function() {
845 $(button).html(originalHtml);
846 }, 2000);
847 });
848}
849
850// Make copyCode available globally
851window.copyCode = copyCode;
852 