Gonçalo Rodrigues 13b7149614 First Commit
2026-06-13 11:25:23 +01:00

267 lines
8.1 KiB
HTML

{{define "content"}}
{{$d := .}}
<h1 style="margin-bottom: 24px;">Portfolio</h1>
{{if $d.Holdings}}
<div class="grid">
<div class="card value-card">
<h2>Total Value</h2>
<div class="value {{if ge $d.TotalPCLCents 0}}positive{{else}}negative{{end}}">
€{{cents $d.TotalValueCents}}
</div>
</div>
<div class="card value-card">
<h2>Total Cost</h2>
<div class="value">€{{cents $d.TotalCostCents}}</div>
</div>
<div class="card value-card">
<h2>Unrealized P&L</h2>
<div class="value {{if ge $d.TotalPCLCents 0}}positive{{else}}negative{{end}}">
{{pctSign $d.TotalPCLPct}}€{{cents $d.TotalPCLCents}}
({{pctSign $d.TotalPCLPct}}{{printf "%.2f" $d.TotalPCLPct}}%)
</div>
</div>
</div>
<div class="card">
<h2>Allocation</h2>
<div style="max-width: 500px; margin: 0 auto; position: relative;">
<canvas id="allocationChart2d" height="300" style="display:none;"></canvas>
<div id="allocation3d" style="width:100%; height:400px;"></div>
</div>
</div>
<div class="card table-wrap">
<table>
<thead>
<tr>
<th>Stock</th>
<th>Shares</th>
<th class="text-right">Avg Entry</th>
<th class="text-right">Current Price</th>
<th class="text-right">Value</th>
<th class="text-right">P&L</th>
<th class="text-right">Return</th>
</tr>
</thead>
<tbody>
{{range $i, $h := $d.Holdings}}
<tr>
<td><strong>{{.Name}}</strong><br><span class="text-muted">{{.ISIN}}</span></td>
<td>{{printf "%.4f" .SharesOwned}}</td>
<td class="cents">€{{cents .AvgEntryCents}}</td>
<td class="cents">€{{cents .CurrentPriceCents}}</td>
<td class="cents">€{{cents .CurrentValueCents}}</td>
<td class="cents {{if ge .UnrealizedPCLCents 0}}positive{{else}}negative{{end}}">
€{{cents .UnrealizedPCLCents}}
</td>
<td class="cents {{if ge .UnrealizedPCLPct 0}}positive{{else}}negative{{end}}">
{{pctSign .UnrealizedPCLPct}}{{printf "%.2f" .UnrealizedPCLPct}}%
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const colors = ['#FF6384','#36A2EB','#FFCE56','#4BC0C0','#9966FF','#FF9F40','#C9CBCF','#00E676','#651FFF','#FF6F00','#E91E63','#607D8B','#3F51B5','#9E9E9E'];
const holdings = [
{{range $d.Holdings}}
{ name: "{{.Name}}", value: {{.CurrentValueCents}} },
{{end}}
];
const total = holdings.reduce((s, h) => s + h.value, 0);
if (total > 0) {
const container = document.getElementById('allocation3d');
const w = container.clientWidth;
const h = 400;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 100);
camera.position.set(0, 4, 8);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(w, h);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
container.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.autoRotate = true;
controls.autoRotateSpeed = 1.5;
controls.minDistance = 4;
controls.maxDistance = 15;
controls.target.set(0, 0, 0);
const group = new THREE.Group();
const innerRadius = 1.2;
const outerRadius = 2.8;
const depth = 0.6;
let angle = 0;
const tooltip = document.createElement('div');
tooltip.style.cssText = 'position:absolute;background:rgba(0,0,0,0.8);color:#fff;padding:6px 12px;border-radius:6px;font-size:13px;pointer-events:none;opacity:0;transition:opacity 0.2s;z-index:10;';
container.appendChild(tooltip);
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let hovered = null;
holdings.forEach((h, i) => {
const arcAngle = (h.value / total) * Math.PI * 2;
const color = colors[i % colors.length];
const shape = new THREE.Shape();
const segments = 32;
const startAngle = angle;
const endAngle = angle + arcAngle;
shape.moveTo(innerRadius, 0);
shape.absarc(0, 0, outerRadius, startAngle, endAngle, false);
shape.absarc(0, 0, innerRadius, endAngle, startAngle, true);
shape.closePath();
const extrudeSettings = {
depth: depth,
bevelEnabled: true,
bevelThickness: 0.08,
bevelSize: 0.04,
bevelSegments: 8,
};
const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
const material = new THREE.MeshPhysicalMaterial({
color: color,
metalness: 0.2,
roughness: 0.3,
clearcoat: 0.1,
side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.rotation.x = -Math.PI / 2;
mesh.position.y = -depth / 2;
mesh.userData = { holding: h };
mesh.castShadow = true;
mesh.receiveShadow = true;
group.add(mesh);
const edgeGeo = new THREE.EdgesGeometry(geometry);
const edgeMat = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.15 });
const edge = new THREE.LineSegments(edgeGeo, edgeMat);
edge.rotation.x = -Math.PI / 2;
edge.position.y = -depth / 2;
group.add(edge);
angle = endAngle;
});
scene.add(group);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight.position.set(5, 10, 7);
dirLight.castShadow = true;
scene.add(dirLight);
const backLight = new THREE.DirectionalLight(0xffffff, 0.4);
backLight.position.set(-5, 0, -5);
scene.add(backLight);
const floorGeo = new THREE.RingGeometry(1.5, 3.5, 64);
const floorMat = new THREE.MeshPhysicalMaterial({
color: 0x1a237e,
transparent: true,
opacity: 0.06,
side: THREE.DoubleSide,
roughness: 0.8,
metalness: 0.1,
});
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -depth / 2 - 0.01;
scene.add(floor);
function onPointerMove(event) {
const rect = renderer.domElement.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObjects(group.children);
if (intersects.length > 0) {
const hit = intersects[0].object;
if (hit.userData.holding) {
const h = hit.userData.holding;
const pct = ((h.value / total) * 100).toFixed(1);
tooltip.textContent = `${h.name}: €${(h.value / 100).toFixed(2)} (${pct}%)`;
tooltip.style.opacity = '1';
tooltip.style.left = (event.clientX - rect.left + 12) + 'px';
tooltip.style.top = (event.clientY - rect.top - 10) + 'px';
if (hovered !== hit) {
if (hovered) {
hovered.material.opacity = 1;
hovered.material.needsUpdate = true;
}
hit.material.opacity = 0.85;
hit.material.needsUpdate = true;
hovered = hit;
}
return;
}
}
tooltip.style.opacity = '0';
if (hovered) {
hovered.material.opacity = 1;
hovered.material.needsUpdate = true;
hovered = null;
}
}
renderer.domElement.addEventListener('pointermove', onPointerMove);
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
function resize() {
const w2 = container.clientWidth;
camera.aspect = w2 / h;
camera.updateProjectionMatrix();
renderer.setSize(w2, h);
}
window.addEventListener('resize', resize);
}
</script>
{{else}}
<div class="card empty-state">
<h3>No trades imported yet</h3>
<p>Go to <a href="/import">Import</a> and upload your Trade Republic securities CSV.</p>
</div>
{{end}}
{{end}}