Add virtual dashboard for vehicles
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
60df25b39a
commit
8ed81f89e8
25
pxy_city_digital_twins/migrations/0001_initial.py
Normal file
25
pxy_city_digital_twins/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
@ -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}"
|
||||
|
@ -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>
|
||||
<!--
|
||||
-->
|
@ -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"),
|
||||
|
||||
|
||||
]
|
||||
|
@ -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"})
|
||||
|
@ -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)
|
||||
|
@ -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 %}
|
||||
|
@ -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">
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user