Add virtual dashboard for vehicles
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Ekaropolus 2025-05-21 20:25:59 -06:00
parent 60df25b39a
commit 8ed81f89e8
10 changed files with 348 additions and 25 deletions

View File

@ -0,0 +1,25 @@
# Generated by Django 5.0.3 on 2025-05-21 21:44
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='WastePickup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('subdivision', models.CharField(max_length=100)),
('vehicle', models.CharField(max_length=50)),
('step_id', models.CharField(max_length=50)),
('quarter', models.PositiveSmallIntegerField()),
('timestamp', models.DateTimeField(auto_now_add=True)),
],
),
]

View File

@ -1,3 +1,11 @@
from django.db import models
# Create your models here.
class WastePickup(models.Model):
subdivision = models.CharField(max_length=100)
vehicle = models.CharField(max_length=50)
step_id = models.CharField(max_length=50)
quarter = models.PositiveSmallIntegerField() # 1 through 4
timestamp = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.subdivision}/{self.vehicle}/{self.step_id} → Q{self.quarter} @ {self.timestamp}"

View File

@ -35,50 +35,61 @@
color="{{ building.color }}"
opacity="0.8">
</a-box>
<a-entity position="{{ building.position_x }} {{ building.height }} {{ building.position_z }}">
</a-entity>
</a-entity>
{% endfor %}
<!-- 2) Draw each job-sphere & gauge from step_positions -->
{% for pos in step_positions %}
<a-entity position="{{ pos.x }} 1 {{ pos.z }}">
<a-sphere radius="10"
{% with sid=pos.step.id stype=pos.step.step_type %}
<a-entity id="step-{{ sid }}" position="{{ pos.x }} 1 {{ pos.z }}">
<!-- sphere now semi-transparent by default -->
<a-sphere id="sphere-{{ sid }}"
radius="10"
color="#FF4136"
emissive="#FF4136"
opacity="0.7">
transparent="true"
opacity="0.4">
</a-sphere>
<!-- gauge above the sphere -->
<a-entity class="status-gauge" gauge-click-toggle position="11 3 0">
{% include "pxy_city_digital_twins/_status_gauge_waste.html" with ring_color="#FF4136" status=pos.step.step_type id=pos.step.id %}
</a-entity>
</a-entity>
{% endfor %}
<!-- gauge above the sphere -->
<a-entity
class="status-gauge"
gauge-click-toggle
position="11 3 0"
data-step-id="{{ sid }}"
data-step-type="{{ stype }}">
{% include "pxy_city_digital_twins/_status_gauge_waste.html" with ring_color="#FF4136" status=stype id=sid %}
</a-entity>
</a-entity>
{% endwith %}
{% endfor %}
</a-scene>
<script>
// Expose Django vars into JS
const subdivision = "{{ subdivision }}";
const vehicle = "{{ vehicle }}";
const recordUrl = "{% url 'waste-record-pickup' subdivision=subdivision vehicle=vehicle %}";
document.querySelector('#scene').addEventListener('loaded', () => {
const sceneEl = document.getElementById('scene');
// 3) rel_coords was passed in context
const rel = {{ rel_coords|safe }};
// Compute bounding box & stageSize
// 3) Compute bounding box & stageSize
const xs = rel.map(r => r[0]), zs = rel.map(r => r[1]);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minZ = Math.min(...zs), maxZ = Math.max(...zs);
const width = maxX - minX, height = maxZ - minZ;
const stageSize = Math.ceil(Math.max(width, height) * 1.2);
// Update invisible base-plane
// 4) Update invisible base-plane
const base = document.getElementById('basePlane');
base.setAttribute('width', width * 1.5);
base.setAttribute('height', height * 1.5);
base.setAttribute('position',
`${(minX+maxX)/2} 0 ${(minZ+maxZ)/2}`);
// Add environment
// 5) Add environment
const envEl = document.createElement('a-entity');
envEl.setAttribute('environment', `
preset: forest;
@ -92,7 +103,7 @@
`);
sceneEl.appendChild(envEl);
// Draw & animate each segment
// 6) Draw & animate each segment
const pulseDuration = 600;
rel.forEach(([x1,z1], i) => {
if (i + 1 >= rel.length) return;
@ -116,16 +127,72 @@
sceneEl.appendChild(seg);
});
// Position camera at first step
// 7) Position camera at first step
if (rel.length) {
const [fx,fz] = rel[0];
const cam = document.getElementById('mainCamera');
cam.setAttribute('position', `${fx.toFixed(2)} 6 ${fz.toFixed(2)+6}`);
cam.setAttribute('look-at', `${fx.toFixed(2)} 1 ${fz.toFixed(2)}`);
}
// ─────────────────────────────────────────────────────────────
// 8) Record pickups by clicking each gauge
// ─────────────────────────────────────────────────────────────
// CSRF helper
function getCookie(name) {
const v = document.cookie.match('(^|;)\\s*'+name+'\\s*=\\s*([^;]+)');
return v ? v.pop() : '';
}
const csrftoken = getCookie('csrftoken');
// Track click counts & define colors
const clickCounts = {};
const colors = ['#2ECC40','#FFDC00','#FF851B','#FF4136'];
document.querySelectorAll('.status-gauge').forEach(el => {
const stepId = el.dataset.stepId;
clickCounts[stepId] = 0;
el.addEventListener('click', () => {
// cycle 1→4
clickCounts[stepId] = (clickCounts[stepId] % 4) + 1;
const quarter = clickCounts[stepId];
// recolor ring
const ring = el.querySelector('.gauge-ring');
if (ring) ring.setAttribute('color', colors[quarter-1]);
// recolor sphere
const sph = document.getElementById(`sphere-${stepId}`);
if (sph) {
sph.setAttribute(
'material',
`color: ${colors[quarter-1]};
emissive: ${colors[quarter-1]};
transparent: true; opacity: 0.4`
);
}
// log & POST
console.log('Recording pickup', { step_id: stepId, quarter }, '→', recordUrl);
fetch(recordUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken
},
body: JSON.stringify({ step_id: stepId, quarter })
}).then(res => {
if (!res.ok) {
console.error('Record pickup failed', res.status);
} else {
console.log('Pickup recorded');
}
});
});
});
});
</script>
</body>
</html>
<!--
-->

View File

@ -14,4 +14,8 @@ urlpatterns = [
path('city/digital/twin/waste/debug/<str:subdivision>/<str:vehicle>/', views.waste_route_debug, name='waste_route_debug'),
path('city/augmented/reality/digital/twin/waste/<str:subdivision>/<str:vehicle>/', views.augmented_waste_route, name='waste_route'),
path('city/augmented/reality/digital/twin/waste/<str:subdivision>/<str:vehicle>/record/', views.record_pickup, name="waste-record-pickup"),
]

View File

@ -284,7 +284,9 @@ def waste_route(request, subdivision, vehicle):
step_positions.append({
'x': x - avg_x,
'z': z - avg_z,
'step': step
'step_id': step.get('id'),
'step_type': step.get('step_type'),
'popup': step.get('popup'),
})
# 7) render
@ -368,3 +370,34 @@ def augmented_waste_route(request, subdivision, vehicle):
'rel_coords': rel_coords,
'step_positions': step_positions,
})
import json
from django.http import JsonResponse, HttpResponseBadRequest
from django.views.decorators.http import require_POST
from django.views.decorators.csrf import csrf_exempt # or use the csrf token client-side
from .models import WastePickup
@csrf_exempt
@require_POST
def record_pickup(request, subdivision, vehicle):
"""
Expect JSON body:
{ "step_id": "<id>", "quarter": <1-4> }
"""
try:
payload = json.loads(request.body)
step_id = payload['step_id']
quarter = int(payload['quarter'])
if quarter not in (1,2,3,4):
raise ValueError
except (KeyError, ValueError, json.JSONDecodeError):
return HttpResponseBadRequest("Invalid payload")
WastePickup.objects.create(
subdivision=subdivision,
vehicle=vehicle,
step_id=step_id,
quarter=quarter
)
return JsonResponse({"status":"ok"})

View File

@ -469,3 +469,50 @@ def apps_facebook_pages_bot(request):
"bots_info": bots_info,
"weekly_data_json": json.dumps(weekly_data),
})
from django.shortcuts import render
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from pxy_city_digital_twins.models import WastePickup
from collections import defaultdict
import json
@login_required
def apps_urban_digital_twin(request):
# ───── Ruta alterna para depuración: JSON completo ─────
if request.GET.get("dump") == "1":
full_table = list(WastePickup.objects.all().values(
"id", "subdivision", "vehicle", "step_id", "quarter", "timestamp"
))
return JsonResponse({"waste_pickup_table": full_table}, safe=False)
# ───── Lógica del gráfico ─────
subdivisions = WastePickup.objects.values_list("subdivision", flat=True).distinct()
selected_subdivision = request.GET.get("subdivision") or (subdivisions[0] if subdivisions else None)
raw = WastePickup.objects.filter(subdivision=selected_subdivision)
data_by_vehicle = defaultdict(lambda: defaultdict(set)) # vehicle → date → set de cuartiles
for pickup in raw:
date_str = pickup.timestamp.strftime("%Y-%m-%d")
veh = f"Vehículo {pickup.vehicle}"
data_by_vehicle[veh][date_str].add(pickup.quarter)
all_points = []
unique_vehicles = set()
for vehicle, date_map in data_by_vehicle.items():
unique_vehicles.add(vehicle)
for date, quarters in date_map.items():
all_points.append({
"x": date,
"y": vehicle,
"z": len(quarters) # cuartiles únicos ese día
})
context = {
"subdivisions": subdivisions,
"selected_subdivision": selected_subdivision,
"scatter_data_json": json.dumps(all_points),
"vehicle_categories": json.dumps(sorted(unique_vehicles)),
}
return render(request, "pxy_dashboard/apps/apps-urban-digital-twin.html", context)

View File

@ -1,5 +1,144 @@
{% extends "pxy_dashboard/partials/base.html" %}
{% load static %}
{% block title %}Urban Digital Twin{% endblock %}
{% block content %}
<h2>Urban Digital Twin</h2>
<p>This is a placeholder page for <code>apps-urban-digital-twin.html</code></p>
{% include "pxy_dashboard/partials/dashboard/kpi_row.html" %}
<div class="row mb-4">
<div class="col-md-4">
<label for="subdivisionSelect" class="form-label">Selecciona una subdivisión:</label>
<select id="subdivisionSelect" class="form-select">
{% for s in subdivisions %}
<option value="{{ s }}" {% if s == selected_subdivision %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Column Chart -->
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0">Column Chart: Cuartiles por Fecha y Vehículo</h5></div>
<div class="card-body">
<div id="chart-bar"></div>
</div>
</div>
<!-- Heatmap -->
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0">Heatmap: Distribución de Cuartiles</h5></div>
<div class="card-body">
<div id="chart-heatmap"></div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
// Datos desde Django
const rawData = {{ scatter_data_json|safe }};
const vehicles = {{ vehicle_categories|safe }};
const subdivision = "{{ selected_subdivision|escapejs }}";
// Escala de color para z
const colorScale = {1:"#FF4136",2:"#FF851B",3:"#FFDC00",4:"#2ECC40",5:"#0074D9"};
// Fechas únicas y ordenadas
const dates = [...new Set(rawData.map(p => p.x))].sort();
// Función para extraer solo dígitos del string "Vehículo X"
function extractVehicleId(label) {
const m = label.match(/\d+/);
return m ? m[0] : label;
}
// --- Column Chart Series ---
const seriesBar = vehicles.map(veh => ({
name: veh,
data: dates.map(d => {
const rec = rawData.find(p => p.x === d && p.y === veh);
return rec ? rec.z : 0;
})
}));
// Render Column Chart con click
new ApexCharts(document.querySelector("#chart-bar"), {
chart: {
type: 'bar',
height: 350,
events: {
dataPointSelection: function(_, __, { seriesIndex, dataPointIndex }) {
const vehLabel = vehicles[seriesIndex];
const vehId = extractVehicleId(vehLabel);
const url = `/city/virtual/reality/digital/twin/waste/${encodeURIComponent(subdivision)}/${encodeURIComponent(vehId)}/`;
window.open(url, "_blank");
}
}
},
plotOptions: { bar: { horizontal: false, columnWidth: '50%' } },
dataLabels: { enabled: false },
series: seriesBar,
xaxis: { categories: dates, title: { text: 'Fecha' } },
yaxis: { title: { text: 'Cuartiles únicos' } },
legend: { position: 'bottom' },
title: { text: `Cuartiles únicos por Fecha en ${subdivision}` }
}).render();
// --- Heatmap Series ---
const seriesHeat = vehicles.map(veh => ({
name: veh,
data: dates.map(d => {
const rec = rawData.find(p => p.x === d && p.y === veh);
return { x: d, y: rec ? rec.z : 0 };
})
}));
// Render Heatmap con click
new ApexCharts(document.querySelector("#chart-heatmap"), {
chart: {
type: 'heatmap',
height: 350,
events: {
dataPointSelection: function(_, __, { seriesIndex, dataPointIndex }) {
const vehLabel = vehicles[seriesIndex];
const vehId = extractVehicleId(vehLabel);
const url = `/city/virtual/reality/digital/twin/waste/${encodeURIComponent(subdivision)}/${encodeURIComponent(vehId)}/`;
window.open(url, "_blank");
}
}
},
series: seriesHeat,
plotOptions: {
heatmap: {
shadeIntensity: 0.5,
colorScale: {
ranges: [
{ from: 0, to: 0, name: '0', color: '#f2f2f2' },
{ from: 1, to: 1, name: '1', color: '#FF4136' },
{ from: 2, to: 2, name: '2', color: '#FF851B' },
{ from: 3, to: 3, name: '3', color: '#FFDC00' },
{ from: 4, to: 4, name: '4', color: '#2ECC40' },
{ from: 5, to: 5, name: '5+', color: '#0074D9' }
]
}
}
},
dataLabels: { enabled: false },
xaxis: { type: 'category', categories: dates, title: { text: 'Fecha' } },
yaxis: { title: { text: 'Vehículo' } },
title: { text: `Heatmap de Cuartiles en ${subdivision}` }
}).render();
// Recarga al cambiar subdivisión
document.getElementById("subdivisionSelect")
.addEventListener("change", function(){
const q = new URLSearchParams({ subdivision: this.value }).toString();
location.search = q;
});
});
</script>
{% endblock %}

View File

@ -41,7 +41,7 @@
<img src="{% static 'dashboard/images/users/avatar-1.jpg' %}" alt="user-image" height="42" class="rounded-circle shadow">
</div>
<div class="flex-grow-1 ms-2">
<span class="fw-semibold fs-15 d-block">Doris Larson</span>
<span class="fw-semibold fs-15 d-block">Cucha Hernández</span>
<span class="fs-13">Founder</span>
</div>
<div class="ms-auto">

View File

@ -41,7 +41,7 @@
<img src="{% static 'dashboard/images/users/avatar-1.jpg' %}" alt="user-image" height="42" class="rounded-circle shadow">
</div>
<div class="flex-grow-1 ms-2">
<span class="fw-semibold fs-15 d-block">Doris Larson</span>
<span class="fw-semibold fs-15 d-block">Cucha Hernández</span>
<span class="fs-13">Founder</span>
</div>
<div class="ms-auto">

View File

@ -347,7 +347,7 @@
<img src="{% static 'dashboard/images/users/avatar-1.jpg' %}" alt="user-image" width="32" class="rounded-circle">
</span>
<span class="d-lg-flex flex-column gap-1 d-none">
<h5 class="my-0">Doris Larson</h5>
<h5 class="my-0">Cucha Hernández</h5>
<h6 class="my-0 fw-normal">Founder</h6>
</span>
</a>