SHELLAPANDIANGANHUNGING commited on
Commit
f2c9198
·
verified ·
1 Parent(s): 0d963ab

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +203 -107
app.py CHANGED
@@ -1147,15 +1147,37 @@ except Exception as e:
1147
  st.error(f"⚠️ Error Risk Map Objective 4: {e}")
1148
  st.exception(e)
1149
 
 
 
 
 
 
 
 
 
 
1150
  # =================== OBJECTIVE 5: Operator Fatigue Risk Gradient Dashboard =====================
1151
- # ✅ Gunakan HTML + CSS (bukan st.subheader), centered, white bg, typo fixed
1152
  st.markdown("""
1153
  <h2 class="objective-header">OBJECTIVE 5: See Your Team’s Fatigue Hazard Profile!</h2>
1154
  """, unsafe_allow_html=True)
1155
 
1156
- # ✅ CUSTOM CSS — SEMUA STRUKTUR DIPERBAIKI & DIPERLUAS
1157
  st.markdown("""
1158
  <style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1159
  /* === OBJECTIVE HEADER === */
1160
  .objective-header {
1161
  font-size: 26px;
@@ -1184,21 +1206,13 @@ st.markdown("""
1184
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1185
  }
1186
 
1187
- .subnote {
1188
- font-size: 16px;
1189
- color: #7f8c8d;
1190
- text-align: center;
1191
- margin-bottom: 20px;
1192
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1193
- }
1194
-
1195
  .section-divider {
1196
  height: 2px;
1197
  background: linear-gradient(to right, #3498db, #2ecc71, #f1c40f, #e74c3c);
1198
  margin: 25px 0;
1199
  }
1200
 
1201
- /* === LEGEND === */
1202
  .legend-container {
1203
  display: flex;
1204
  gap: 20px;
@@ -1211,8 +1225,9 @@ st.markdown("""
1211
  border: 1px solid #ddd;
1212
  border-radius: 10px;
1213
  padding: 16px;
1214
- min-width: 290px;
1215
- max-width: 330px;
 
1216
  box-shadow: 0 2px 8px rgba(0,0,0,0.05);
1217
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1218
  }
@@ -1227,7 +1242,7 @@ st.markdown("""
1227
  .legend-item {
1228
  display: flex;
1229
  align-items: center;
1230
- margin: 6px 0;
1231
  font-size: 13px;
1232
  }
1233
  .legend-color {
@@ -1236,6 +1251,7 @@ st.markdown("""
1236
  border-radius: 3px;
1237
  margin-right: 10px;
1238
  border: 1px solid #ccc;
 
1239
  }
1240
  .legend-note {
1241
  font-size: 12px;
@@ -1300,11 +1316,38 @@ st.markdown("""
1300
  /* === TRENDS === */
1301
  .trend-up { color: #e74c3c; font-weight: bold; }
1302
  .trend-down { color: #27ae60; font-weight: bold; }
 
 
 
1303
  </style>
1304
  """, unsafe_allow_html=True)
1305
 
1306
  # ===============================================================
1307
- # LOGIC UTAMA DIPERBAIKI: PENDEKAN NAMA OPERATOR & KONSISTENSI
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1308
  # ===============================================================
1309
  if df.empty:
1310
  st.info("No data available after applying filters.")
@@ -1323,17 +1366,13 @@ else:
1323
  .astype(str)
1324
  .str.strip()
1325
  .str.split()
1326
- .str[0] # Only first part
1327
  )
1328
 
1329
  if df_op.empty:
1330
  st.info("No operator data after filtering.")
1331
  st.stop()
1332
 
1333
- if col_operator is None:
1334
- st.error("Operator column could not be auto-detected. Please check your data.")
1335
- st.stop()
1336
-
1337
  df_op["year_week"] = df_op["start"].dt.strftime("%Y-W%U")
1338
 
1339
  # Fuzzy match fleet names
@@ -1344,7 +1383,6 @@ else:
1344
  ob_data = df_op[df_op["is_ob"]]
1345
  coal_data = df_op[df_op["is_coal"]]
1346
 
1347
- # Fungsi analisis — tetap sama
1348
  def get_top10_with_slope(data):
1349
  if data.empty: return pd.DataFrame()
1350
  if col_operator not in data.columns:
@@ -1381,41 +1419,41 @@ else:
1381
  top_coal = get_top10_with_slope(coal_data)
1382
 
1383
  # ===============================================================
1384
- # LEGEND — DIPERBAIKI: LABEL & NOTES SESUAI PREFERENSI
1385
  # ===============================================================
1386
  st.markdown('<h3 class="big-title">Hazard Gradient Legend</h3>', unsafe_allow_html=True)
1387
  st.markdown("""
1388
  <div class="legend-container">
1389
- <!-- Worsening Trends (Positive Slope) -->
1390
  <div class="legend-box">
1391
  <div class="legend-title">Worsening Trends (Positive Slope):</div>
1392
  <div class="legend-item">
1393
  <div class="legend-color" style="background-color: #d32f2f;"></div>
1394
- <span>Very High Risk (≥1.5)</span>
1395
  </div>
1396
  <div class="legend-item">
1397
  <div class="legend-color" style="background-color: #e57373;"></div>
1398
- <span>High Risk (1.0–1.5)</span>
1399
  </div>
1400
  <div class="legend-item">
1401
  <div class="legend-color" style="background-color: #ef9a9a;"></div>
1402
- <span>Moderate Risk (0.5–1.0)</span>
1403
  </div>
1404
  <div class="legend-item">
1405
  <div class="legend-color" style="background-color: #ffcdd2;"></div>
1406
- <span>Slight Risk (0–0.5)</span>
1407
  </div>
1408
  <p class="legend-note">
1409
  <i>Note: Positive slope indicates increasing fatigue events over time — escalating operational risk.</i>
1410
  </p>
1411
  </div>
1412
 
1413
- <!-- Improving Trends (Negative Slope) -->
1414
  <div class="legend-box">
1415
  <div class="legend-title">Improving Trends (Negative Slope):</div>
1416
  <div class="legend-item">
1417
  <div class="legend-color" style="background-color: #388e3c;"></div>
1418
- <span>Excellent Improvement (≤−1.5)</span>
1419
  </div>
1420
  <div class="legend-item">
1421
  <div class="legend-color" style="background-color: #81c784;"></div>
@@ -1434,7 +1472,7 @@ else:
1434
  </p>
1435
  </div>
1436
 
1437
- <!-- One-Time Events (Zero Slope) -->
1438
  <div class="legend-box">
1439
  <div class="legend-title">One-Time Events (Zero Slope):</div>
1440
  <div class="legend-item">
@@ -1449,17 +1487,23 @@ else:
1449
  """, unsafe_allow_html=True)
1450
 
1451
  # ===============================================================
1452
- # PLOT FUNCTION — LOGIKA WARNA SESUAI KATEGORI RISK
1453
  # ===============================================================
1454
  def plot_chart(data, title):
1455
  if data.empty:
1456
  fig = go.Figure()
1457
- fig.add_annotation(text="No Data", x=0.5, y=0.5, showarrow=False, font_size=16, font_color="#888")
 
 
 
 
 
1458
  fig.update_layout(
1459
  height=350,
1460
  title=dict(text=title, x=0.5, font=dict(size=18, family="Segoe UI")),
1461
  plot_bgcolor="rgba(0,0,0,0)",
1462
- paper_bgcolor="rgba(0,0,0,0)"
 
1463
  )
1464
  return fig
1465
 
@@ -1467,13 +1511,13 @@ else:
1467
 
1468
  def get_color(slope):
1469
  if slope == 0:
1470
- return "#FFD700" # One Time Event → yellow
1471
- elif slope > 0: # Risk tiers
1472
  if slope >= 1.5: return "#d32f2f"
1473
  elif slope >= 1.0: return "#e57373"
1474
  elif slope >= 0.5: return "#ef9a9a"
1475
  else: return "#ffcdd2"
1476
- else: # Improvement tiers
1477
  if slope <= -1.5: return "#388e3c"
1478
  elif slope <= -1.0: return "#81c784"
1479
  elif slope <= -0.5: return "#a5d6a7"
@@ -1484,7 +1528,7 @@ else:
1484
  bar_trace = go.Bar(
1485
  x=data_sorted[col_operator].astype(str),
1486
  y=data_sorted["weekly_avg"],
1487
- marker=dict(color=colors, line=dict(width=1.5, color="rgba(0,0,0,0.2)")),
1488
  text=[f"{v:.1f}" for v in data_sorted["weekly_avg"]],
1489
  textposition="outside",
1490
  textfont=dict(size=11, family="Segoe UI"),
@@ -1502,91 +1546,110 @@ else:
1502
  fig = go.Figure(bar_trace)
1503
  fig.update_layout(
1504
  title=dict(text=f"<b>{title}</b>", x=0.5, font=dict(size=18, color="#2c3e50")),
1505
- height=460,
1506
- margin=dict(l=50, r=20, t=70, b=130),
1507
  xaxis_title=dict(text="<b>Operator</b>", font=dict(family="Segoe UI")),
1508
  yaxis_title=dict(text="<b>Weekly Avg Events</b>", font=dict(family="Segoe UI")),
1509
  font=dict(family="Segoe UI", size=12),
1510
  bargap=0.3,
1511
  plot_bgcolor="rgba(0,0,0,0)",
1512
  paper_bgcolor="rgba(0,0,0,0)",
1513
- xaxis=dict(tickangle=45, tickfont=dict(family="Segoe UI")),
 
 
 
 
1514
  yaxis=dict(gridcolor="#eee")
1515
  )
 
 
1516
  return fig
1517
 
1518
  # ===============================================================
1519
- # CHARTS
1520
  # ===============================================================
1521
- col1, col2 = st.columns(2)
1522
- with col1:
1523
  st.plotly_chart(plot_chart(top_ob, "OB HAULER Operators (Hazard Gradient)"), use_container_width=True)
1524
- with col2:
1525
  st.plotly_chart(plot_chart(top_coal, "HAULING COAL Operators (Hazard Gradient)"), use_container_width=True)
 
 
 
 
 
 
1526
 
1527
  # ===============================================================
1528
- # AI INSIGHTS — "Risk Summary", centered title
1529
  # ===============================================================
1530
- col_insight1, col_insight2 = st.columns(2)
1531
-
1532
- with col_insight1:
1533
  if not top_ob.empty:
1534
  st.markdown('<h3 class="big-title">OB HAULER Analysis</h3>', unsafe_allow_html=True)
1535
- ob_worsening = len(top_ob[top_ob['slope'] > 0])
1536
- ob_improving = len(top_ob[top_ob['slope'] < 0])
1537
- ob_one_time = len(top_ob[top_ob['slope'] == 0])
1538
- ob_avg_risk = top_ob['weekly_avg'].mean()
1539
- ob_max_risk = top_ob['weekly_avg'].max()
1540
-
1541
- insights = []
1542
- if ob_worsening > ob_improving:
1543
- insights.append(f"{ob_worsening} out of 10 top-risk operators show <span class='trend-up'>worsening</span> trends.")
1544
- else:
1545
- insights.append(f"{ob_improving} out of 10 top-risk operators show <span class='trend-down'>improvement</span>.")
1546
- if ob_one_time > 0:
1547
- insights.append(f"{ob_one_time} operator(s) classified as <b>One Time Event</b>.")
1548
- insights.append(f"Average risk: {ob_avg_risk:.2f} events/week (max: {ob_max_risk:.2f}).")
1549
-
1550
- for txt in insights:
1551
- st.markdown(f"""
1552
- <div class="ai-insight-box">
1553
- <div class="ai-insight-title">Risk Summary</div>
1554
- <p>{txt}</p>
1555
- </div>
1556
- """, unsafe_allow_html=True)
1557
- else:
1558
- st.info("No OB HAULER data for analysis.")
1559
-
1560
- with col_insight2:
1561
  if not top_coal.empty:
1562
  st.markdown('<h3 class="big-title">HAULING COAL Analysis</h3>', unsafe_allow_html=True)
1563
- coal_worsening = len(top_coal[top_coal['slope'] > 0])
1564
- coal_improving = len(top_coal[top_coal['slope'] < 0])
1565
- coal_one_time = len(top_coal[top_coal['slope'] == 0])
1566
- coal_avg_risk = top_coal['weekly_avg'].mean()
1567
- coal_max_risk = top_coal['weekly_avg'].max()
1568
-
1569
- insights = []
1570
- if coal_worsening > coal_improving:
1571
- insights.append(f"{coal_worsening} out of 10 top-risk operators show <span class='trend-up'>worsening</span> trends.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1572
  else:
1573
- insights.append(f"{coal_improving} out of 10 top-risk operators show <span class='trend-down'>improvement</span>.")
1574
- if coal_one_time > 0:
1575
- insights.append(f"{coal_one_time} operator(s) classified as <b>One Time Event</b>.")
1576
- insights.append(f"Average risk: {coal_avg_risk:.2f} events/week (max: {coal_max_risk:.2f}).")
1577
-
1578
- for txt in insights:
1579
- st.markdown(f"""
1580
- <div class="ai-insight-box">
1581
- <div class="ai-insight-title">Risk Summary</div>
1582
- <p>{txt}</p>
1583
- </div>
1584
- """, unsafe_allow_html=True)
1585
- else:
1586
- st.info("No HAULING COAL data for analysis.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1587
 
1588
  # ===============================================================
1589
- # RECOMMENDATIONS
1590
  # ===============================================================
1591
  def generate_recommendations(top_ob, top_coal):
1592
  rec = {}
@@ -1613,8 +1676,7 @@ else:
1613
 
1614
  ai_rec = generate_recommendations(top_ob, top_coal)
1615
 
1616
- col_rec1, col_rec2 = st.columns(2)
1617
- with col_rec1:
1618
  if 'ob' in ai_rec:
1619
  st.markdown('<h3 class="big-title">OB HAULER Recommendations</h3>', unsafe_allow_html=True)
1620
  st.markdown(f"""
@@ -1624,10 +1686,6 @@ else:
1624
  <div class="recommendation-reason">AI Reasoning: {ai_rec['ob_reason']}</div>
1625
  </div>
1626
  """, unsafe_allow_html=True)
1627
- else:
1628
- st.info("No OB HAULER recommendations.")
1629
-
1630
- with col_rec2:
1631
  if 'coal' in ai_rec:
1632
  st.markdown('<h3 class="big-title">HAULING COAL Recommendations</h3>', unsafe_allow_html=True)
1633
  st.markdown(f"""
@@ -1637,13 +1695,51 @@ else:
1637
  <div class="recommendation-reason">AI Reasoning: {ai_rec['coal_reason']}</div>
1638
  </div>
1639
  """, unsafe_allow_html=True)
1640
- else:
1641
- st.info("No HAULING COAL recommendations.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1642
 
1643
  except Exception as e:
1644
  st.error(f"Error in Top 10 Operator analysis: {str(e)}")
1645
  # st.exception(e) # Uncomment for debugging
1646
- # st.exception(e) # Uncomment during development
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1647
  # =================== OBJECTIVE 6: Automated Insights & AI Recommendations =====================
1648
  st.subheader("OBJECTIVE 6: Instant Insights & Recommendations")
1649
 
 
1147
  st.error(f"⚠️ Error Risk Map Objective 4: {e}")
1148
  st.exception(e)
1149
 
1150
+
1151
+ # st.exception(e) # Uncomment during development
1152
+
1153
+
1154
+ import streamlit as st
1155
+ import pandas as pd
1156
+ import numpy as np
1157
+ import plotly.graph_objects as go
1158
+
1159
  # =================== OBJECTIVE 5: Operator Fatigue Risk Gradient Dashboard =====================
 
1160
  st.markdown("""
1161
  <h2 class="objective-header">OBJECTIVE 5: See Your Team’s Fatigue Hazard Profile!</h2>
1162
  """, unsafe_allow_html=True)
1163
 
1164
+ # ✅ CUSTOM CSS — RESPONSIVE & SESUAI PREFERENSI
1165
  st.markdown("""
1166
  <style>
1167
+ /* Responsiveness: base font & container */
1168
+ @media (max-width: 768px) {
1169
+ .objective-header { font-size: 22px; padding: 12px; }
1170
+ .big-title { font-size: 20px; padding: 10px; }
1171
+ .legend-container { flex-direction: column; gap: 15px; }
1172
+ .legend-box { min-width: 100% !important; max-width: none; }
1173
+ .ai-insight-box, .recommendation-box { padding: 14px; }
1174
+ }
1175
+ @media (max-width: 480px) {
1176
+ .objective-header { font-size: 20px; }
1177
+ .legend-item span { font-size: 12px; }
1178
+ .legend-note { font-size: 11px; }
1179
+ }
1180
+
1181
  /* === OBJECTIVE HEADER === */
1182
  .objective-header {
1183
  font-size: 26px;
 
1206
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1207
  }
1208
 
 
 
 
 
 
 
 
 
1209
  .section-divider {
1210
  height: 2px;
1211
  background: linear-gradient(to right, #3498db, #2ecc71, #f1c40f, #e74c3c);
1212
  margin: 25px 0;
1213
  }
1214
 
1215
+ /* === LEGEND — RESPONSIVE FLEXBOX === */
1216
  .legend-container {
1217
  display: flex;
1218
  gap: 20px;
 
1225
  border: 1px solid #ddd;
1226
  border-radius: 10px;
1227
  padding: 16px;
1228
+ min-width: 280px;
1229
+ flex: 1;
1230
+ max-width: 340px;
1231
  box-shadow: 0 2px 8px rgba(0,0,0,0.05);
1232
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1233
  }
 
1242
  .legend-item {
1243
  display: flex;
1244
  align-items: center;
1245
+ margin: 5px 0;
1246
  font-size: 13px;
1247
  }
1248
  .legend-color {
 
1251
  border-radius: 3px;
1252
  margin-right: 10px;
1253
  border: 1px solid #ccc;
1254
+ flex-shrink: 0;
1255
  }
1256
  .legend-note {
1257
  font-size: 12px;
 
1316
  /* === TRENDS === */
1317
  .trend-up { color: #e74c3c; font-weight: bold; }
1318
  .trend-down { color: #27ae60; font-weight: bold; }
1319
+
1320
+ /* Plotly responsive fix */
1321
+ .js-plotly-plot .plotly > div { max-width: 100% !important; }
1322
  </style>
1323
  """, unsafe_allow_html=True)
1324
 
1325
  # ===============================================================
1326
+ # CONTOH DATA (GANTI DENGAN DATA ANDA)
1327
+ # ===============================================================
1328
+ # Simulasi data — ganti dengan df Anda
1329
+ @st.cache_data
1330
+ def generate_sample_data():
1331
+ np.random.seed(42)
1332
+ operators = [f"OP{i:03d}" for i in range(1, 51)]
1333
+ fleets = ["OB HAULLER"] * 25 + ["HAULING COAL"] * 25
1334
+ dates = pd.date_range("2025-01-01", "2025-03-31", freq="D")
1335
+
1336
+ data = []
1337
+ for op, fleet in zip(operators, fleets):
1338
+ n_events = np.random.randint(5, 50)
1339
+ for _ in range(n_events):
1340
+ start = np.random.choice(dates)
1341
+ data.append({"Operator": op, "Fleet_Type": fleet, "start": start})
1342
+ return pd.DataFrame(data)
1343
+
1344
+ # Ganti ini dengan df Anda
1345
+ df = generate_sample_data()
1346
+ col_operator = "Operator"
1347
+ col_fleet_type = "Fleet_Type"
1348
+
1349
+ # ===============================================================
1350
+ # LOGIC UTAMA — RESPONSIVE & SESUAI PREFERENSI
1351
  # ===============================================================
1352
  if df.empty:
1353
  st.info("No data available after applying filters.")
 
1366
  .astype(str)
1367
  .str.strip()
1368
  .str.split()
1369
+ .str[0]
1370
  )
1371
 
1372
  if df_op.empty:
1373
  st.info("No operator data after filtering.")
1374
  st.stop()
1375
 
 
 
 
 
1376
  df_op["year_week"] = df_op["start"].dt.strftime("%Y-W%U")
1377
 
1378
  # Fuzzy match fleet names
 
1383
  ob_data = df_op[df_op["is_ob"]]
1384
  coal_data = df_op[df_op["is_coal"]]
1385
 
 
1386
  def get_top10_with_slope(data):
1387
  if data.empty: return pd.DataFrame()
1388
  if col_operator not in data.columns:
 
1419
  top_coal = get_top10_with_slope(coal_data)
1420
 
1421
  # ===============================================================
1422
+ # LEGEND — RESPONSIVE & SESUAI PREFERENSI
1423
  # ===============================================================
1424
  st.markdown('<h3 class="big-title">Hazard Gradient Legend</h3>', unsafe_allow_html=True)
1425
  st.markdown("""
1426
  <div class="legend-container">
1427
+ <!-- Worsening Trends -->
1428
  <div class="legend-box">
1429
  <div class="legend-title">Worsening Trends (Positive Slope):</div>
1430
  <div class="legend-item">
1431
  <div class="legend-color" style="background-color: #d32f2f;"></div>
1432
+ <span>Very High Risk (≥ 1.5)</span>
1433
  </div>
1434
  <div class="legend-item">
1435
  <div class="legend-color" style="background-color: #e57373;"></div>
1436
+ <span>High Risk (1.0 1.5)</span>
1437
  </div>
1438
  <div class="legend-item">
1439
  <div class="legend-color" style="background-color: #ef9a9a;"></div>
1440
+ <span>Moderate Risk (0.5 1.0)</span>
1441
  </div>
1442
  <div class="legend-item">
1443
  <div class="legend-color" style="background-color: #ffcdd2;"></div>
1444
+ <span>Slight Risk (0 0.5)</span>
1445
  </div>
1446
  <p class="legend-note">
1447
  <i>Note: Positive slope indicates increasing fatigue events over time — escalating operational risk.</i>
1448
  </p>
1449
  </div>
1450
 
1451
+ <!-- Improving Trends -->
1452
  <div class="legend-box">
1453
  <div class="legend-title">Improving Trends (Negative Slope):</div>
1454
  <div class="legend-item">
1455
  <div class="legend-color" style="background-color: #388e3c;"></div>
1456
+ <span>Excellent Improvement (≤ −1.5)</span>
1457
  </div>
1458
  <div class="legend-item">
1459
  <div class="legend-color" style="background-color: #81c784;"></div>
 
1472
  </p>
1473
  </div>
1474
 
1475
+ <!-- One-Time Events -->
1476
  <div class="legend-box">
1477
  <div class="legend-title">One-Time Events (Zero Slope):</div>
1478
  <div class="legend-item">
 
1487
  """, unsafe_allow_html=True)
1488
 
1489
  # ===============================================================
1490
+ # PLOT FUNCTION — RESPONSIVE
1491
  # ===============================================================
1492
  def plot_chart(data, title):
1493
  if data.empty:
1494
  fig = go.Figure()
1495
+ fig.add_annotation(
1496
+ text="No Data", x=0.5, y=0.5,
1497
+ showarrow=False,
1498
+ font_size=16,
1499
+ font_color="#888"
1500
+ )
1501
  fig.update_layout(
1502
  height=350,
1503
  title=dict(text=title, x=0.5, font=dict(size=18, family="Segoe UI")),
1504
  plot_bgcolor="rgba(0,0,0,0)",
1505
+ paper_bgcolor="rgba(0,0,0,0)",
1506
+ margin=dict(l=40, r=20, t=60, b=100),
1507
  )
1508
  return fig
1509
 
 
1511
 
1512
  def get_color(slope):
1513
  if slope == 0:
1514
+ return "#FFD700"
1515
+ elif slope > 0:
1516
  if slope >= 1.5: return "#d32f2f"
1517
  elif slope >= 1.0: return "#e57373"
1518
  elif slope >= 0.5: return "#ef9a9a"
1519
  else: return "#ffcdd2"
1520
+ else:
1521
  if slope <= -1.5: return "#388e3c"
1522
  elif slope <= -1.0: return "#81c784"
1523
  elif slope <= -0.5: return "#a5d6a7"
 
1528
  bar_trace = go.Bar(
1529
  x=data_sorted[col_operator].astype(str),
1530
  y=data_sorted["weekly_avg"],
1531
+ marker=dict(color=colors, line=dict(width=1.2, color="rgba(0,0,0,0.2)")),
1532
  text=[f"{v:.1f}" for v in data_sorted["weekly_avg"]],
1533
  textposition="outside",
1534
  textfont=dict(size=11, family="Segoe UI"),
 
1546
  fig = go.Figure(bar_trace)
1547
  fig.update_layout(
1548
  title=dict(text=f"<b>{title}</b>", x=0.5, font=dict(size=18, color="#2c3e50")),
1549
+ height=450,
1550
+ margin=dict(l=50, r=20, t=60, b=120),
1551
  xaxis_title=dict(text="<b>Operator</b>", font=dict(family="Segoe UI")),
1552
  yaxis_title=dict(text="<b>Weekly Avg Events</b>", font=dict(family="Segoe UI")),
1553
  font=dict(family="Segoe UI", size=12),
1554
  bargap=0.3,
1555
  plot_bgcolor="rgba(0,0,0,0)",
1556
  paper_bgcolor="rgba(0,0,0,0)",
1557
+ xaxis=dict(
1558
+ tickangle=45,
1559
+ tickfont=dict(family="Segoe UI", size=11),
1560
+ automargin=True
1561
+ ),
1562
  yaxis=dict(gridcolor="#eee")
1563
  )
1564
+ # Responsif: nonaktifkan zoom & pan di mobile
1565
+ fig.update_yaxes(fixedrange=True)
1566
  return fig
1567
 
1568
  # ===============================================================
1569
+ # CHARTS — RESPONSIVE COLUMN
1570
  # ===============================================================
1571
+ if st.session_state.get("is_mobile", False) or st._get_query_params().get("mobile"):
1572
+ # Force single column on mobile
1573
  st.plotly_chart(plot_chart(top_ob, "OB HAULER Operators (Hazard Gradient)"), use_container_width=True)
 
1574
  st.plotly_chart(plot_chart(top_coal, "HAULING COAL Operators (Hazard Gradient)"), use_container_width=True)
1575
+ else:
1576
+ col1, col2 = st.columns(2)
1577
+ with col1:
1578
+ st.plotly_chart(plot_chart(top_ob, "OB HAULER Operators (Hazard Gradient)"), use_container_width=True)
1579
+ with col2:
1580
+ st.plotly_chart(plot_chart(top_coal, "HAULING COAL Operators (Hazard Gradient)"), use_container_width=True)
1581
 
1582
  # ===============================================================
1583
+ # AI INSIGHTS — RESPONSIVE
1584
  # ===============================================================
1585
+ if st.session_state.get("is_mobile", False) or st._get_query_params().get("mobile"):
1586
+ # Single column
 
1587
  if not top_ob.empty:
1588
  st.markdown('<h3 class="big-title">OB HAULER Analysis</h3>', unsafe_allow_html=True)
1589
+ # ... (same logic as below)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1590
  if not top_coal.empty:
1591
  st.markdown('<h3 class="big-title">HAULING COAL Analysis</h3>', unsafe_allow_html=True)
1592
+ # ...
1593
+ else:
1594
+ col_insight1, col_insight2 = st.columns(2)
1595
+ with col_insight1:
1596
+ if not top_ob.empty:
1597
+ st.markdown('<h3 class="big-title">OB HAULER Analysis</h3>', unsafe_allow_html=True)
1598
+ ob_worsening = len(top_ob[top_ob['slope'] > 0])
1599
+ ob_improving = len(top_ob[top_ob['slope'] < 0])
1600
+ ob_one_time = len(top_ob[top_ob['slope'] == 0])
1601
+ ob_avg_risk = top_ob['weekly_avg'].mean()
1602
+ ob_max_risk = top_ob['weekly_avg'].max()
1603
+
1604
+ insights = []
1605
+ if ob_worsening > ob_improving:
1606
+ insights.append(f"{ob_worsening} out of 10 top-risk operators show <span class='trend-up'>worsening</span> trends.")
1607
+ else:
1608
+ insights.append(f"{ob_improving} out of 10 top-risk operators show <span class='trend-down'>improvement</span>.")
1609
+ if ob_one_time > 0:
1610
+ insights.append(f"{ob_one_time} operator(s) classified as <b>One Time Event</b>.")
1611
+ insights.append(f"Average risk: {ob_avg_risk:.2f} events/week (max: {ob_max_risk:.2f}).")
1612
+
1613
+ for txt in insights:
1614
+ st.markdown(f"""
1615
+ <div class="ai-insight-box">
1616
+ <div class="ai-insight-title">Risk Summary</div>
1617
+ <p>{txt}</p>
1618
+ </div>
1619
+ """, unsafe_allow_html=True)
1620
  else:
1621
+ st.info("No OB HAULER data for analysis.")
1622
+
1623
+ with col_insight2:
1624
+ if not top_coal.empty:
1625
+ st.markdown('<h3 class="big-title">HAULING COAL Analysis</h3>', unsafe_allow_html=True)
1626
+ coal_worsening = len(top_coal[top_coal['slope'] > 0])
1627
+ coal_improving = len(top_coal[top_coal['slope'] < 0])
1628
+ coal_one_time = len(top_coal[top_coal['slope'] == 0])
1629
+ coal_avg_risk = top_coal['weekly_avg'].mean()
1630
+ coal_max_risk = top_coal['weekly_avg'].max()
1631
+
1632
+ insights = []
1633
+ if coal_worsening > coal_improving:
1634
+ insights.append(f"{coal_worsening} out of 10 top-risk operators show <span class='trend-up'>worsening</span> trends.")
1635
+ else:
1636
+ insights.append(f"{coal_improving} out of 10 top-risk operators show <span class='trend-down'>improvement</span>.")
1637
+ if coal_one_time > 0:
1638
+ insights.append(f"{coal_one_time} operator(s) classified as <b>One Time Event</b>.")
1639
+ insights.append(f"Average risk: {coal_avg_risk:.2f} events/week (max: {coal_max_risk:.2f}).")
1640
+
1641
+ for txt in insights:
1642
+ st.markdown(f"""
1643
+ <div class="ai-insight-box">
1644
+ <div class="ai-insight-title">Risk Summary</div>
1645
+ <p>{txt}</p>
1646
+ </div>
1647
+ """, unsafe_allow_html=True)
1648
+ else:
1649
+ st.info("No HAULING COAL data for analysis.")
1650
 
1651
  # ===============================================================
1652
+ # RECOMMENDATIONS — RESPONSIVE
1653
  # ===============================================================
1654
  def generate_recommendations(top_ob, top_coal):
1655
  rec = {}
 
1676
 
1677
  ai_rec = generate_recommendations(top_ob, top_coal)
1678
 
1679
+ if st.session_state.get("is_mobile", False) or st._get_query_params().get("mobile"):
 
1680
  if 'ob' in ai_rec:
1681
  st.markdown('<h3 class="big-title">OB HAULER Recommendations</h3>', unsafe_allow_html=True)
1682
  st.markdown(f"""
 
1686
  <div class="recommendation-reason">AI Reasoning: {ai_rec['ob_reason']}</div>
1687
  </div>
1688
  """, unsafe_allow_html=True)
 
 
 
 
1689
  if 'coal' in ai_rec:
1690
  st.markdown('<h3 class="big-title">HAULING COAL Recommendations</h3>', unsafe_allow_html=True)
1691
  st.markdown(f"""
 
1695
  <div class="recommendation-reason">AI Reasoning: {ai_rec['coal_reason']}</div>
1696
  </div>
1697
  """, unsafe_allow_html=True)
1698
+ else:
1699
+ col_rec1, col_rec2 = st.columns(2)
1700
+ with col_rec1:
1701
+ if 'ob' in ai_rec:
1702
+ st.markdown('<h3 class="big-title">OB HAULER Recommendations</h3>', unsafe_allow_html=True)
1703
+ st.markdown(f"""
1704
+ <div class="recommendation-box">
1705
+ <div class="recommendation-title">Action Plan</div>
1706
+ <div>{ai_rec['ob']}</div>
1707
+ <div class="recommendation-reason">AI Reasoning: {ai_rec['ob_reason']}</div>
1708
+ </div>
1709
+ """, unsafe_allow_html=True)
1710
+ else:
1711
+ st.info("No OB HAULER recommendations.")
1712
+ with col_rec2:
1713
+ if 'coal' in ai_rec:
1714
+ st.markdown('<h3 class="big-title">HAULING COAL Recommendations</h3>', unsafe_allow_html=True)
1715
+ st.markdown(f"""
1716
+ <div class="recommendation-box">
1717
+ <div class="recommendation-title">Action Plan</div>
1718
+ <div>{ai_rec['coal']}</div>
1719
+ <div class="recommendation-reason">AI Reasoning: {ai_rec['coal_reason']}</div>
1720
+ </div>
1721
+ """, unsafe_allow_html=True)
1722
+ else:
1723
+ st.info("No HAULING COAL recommendations.")
1724
 
1725
  except Exception as e:
1726
  st.error(f"Error in Top 10 Operator analysis: {str(e)}")
1727
  # st.exception(e) # Uncomment for debugging
1728
+
1729
+ # ✅ Auto-detect mobile (opsional)
1730
+ def detect_mobile():
1731
+ try:
1732
+ from streamlit.runtime.scriptrunner import get_script_run_ctx
1733
+ ctx = get_script_run_ctx()
1734
+ if ctx and ctx.session_id:
1735
+ user_agent = st.context.headers.get("User-Agent", "").lower()
1736
+ return any(x in user_agent for x in ["mobile", "android", "iphone", "ipad"])
1737
+ except:
1738
+ pass
1739
+ return False
1740
+
1741
+ if "is_mobile" not in st.session_state:
1742
+ st.session_state.is_mobile = detect_mobile()
1743
  # =================== OBJECTIVE 6: Automated Insights & AI Recommendations =====================
1744
  st.subheader("OBJECTIVE 6: Instant Insights & Recommendations")
1745