Team Ai
Apppublic

blackopsrepl/portfolio-optimization-python

sourceHugging Faceapache-2.0updated 10mo agoView on Hugging Face
1likes
app.js852 linesDownload Raw Back to static
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