Skip to content

Commit 165cf23

Browse files
authored
DBC22-5603: corrected display message line style (#1218)
1 parent a79278b commit 165cf23

6 files changed

Lines changed: 175 additions & 81 deletions

File tree

src/backend/apps/dms/models.py

Lines changed: 122 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,142 @@
11
from apps.shared.models import BaseModel
22
from django.contrib.gis.db import models
3-
from django.contrib.gis.geos import Point
43
from django.core.cache import cache
54
from django_prometheus.models import ExportModelOperationsMixin
65
from apps.shared.enums import CacheKey
76
import re
87
from typing import Tuple
98
from django.utils.dateparse import parse_datetime
10-
# from django.utils import timezone
119
from datetime import timezone as dt_timezone
1210

1311
NTCIP_TOKEN_PATTERN = re.compile(r"\[[^\]]+\]")
1412

13+
def parse_api_utc(dt_str):
14+
if not dt_str:
15+
return None
16+
17+
dt = parse_datetime(dt_str)
18+
19+
if dt is None:
20+
return None
21+
22+
if dt.tzinfo is None:
23+
dt = dt.replace(tzinfo=dt_timezone.utc)
24+
25+
return dt.astimezone(dt_timezone.utc)
26+
27+
def create_structured_message(content: str, alignment: str) -> dict:
28+
return {
29+
"content": content.strip(),
30+
"alignment": alignment # "left", "center", "right"
31+
}
32+
33+
def clean_tags(text):
34+
"""Removes NTCIP tags to find the true visible character count."""
35+
# Matches anything in brackets like [jl2], [fo3], [pt30o0]
36+
return re.sub(r'\[[^\]]*\]?', '', text).strip()
37+
38+
def convert_message(input_string: str) -> list:
39+
"""
40+
Convert message with justification tokens to structured alignment format.
41+
Returns list of dictionaries with content and alignment information.
42+
"""
43+
# Split by both page [np] and line [nl] markers
44+
all_segments = re.split(r'\[nl\]|\[np\]', input_string)
45+
46+
for segment in all_segments:
47+
# Replace the justification token with a single space to measure baseline length
48+
clean_segment = segment.replace('[jl4]', ' ').replace('__JUSTIFY_RIGHT__', ' ')
49+
visible_len = len(clean_tags(clean_segment))
50+
51+
pages = input_string.split('[np]')
52+
all_processed_lines = []
53+
54+
for page in pages:
55+
raw_lines = page.split('[nl]')
56+
57+
for line in raw_lines:
58+
visible_content = clean_tags(line)
59+
60+
# Filter out empty lines or stray tag fragments
61+
if not visible_content and '[jl4]' not in line and '__JUSTIFY_RIGHT__' not in line:
62+
continue
63+
if '[' in visible_content and ']' not in visible_content:
64+
continue
65+
# Check for Right-Justification markers
66+
if '[jl4]' in line or '__JUSTIFY_RIGHT__' in line:
67+
# Split at whichever token is present
68+
token = '[jl4]' if '[jl4]' in line else '__JUSTIFY_RIGHT__'
69+
parts = line.split(token)
70+
71+
left = clean_tags(parts[0])
72+
right = clean_tags(parts[1])
73+
combined_content = f"{left} {right}".strip()
74+
75+
# Create structured data for right alignment
76+
all_processed_lines.append({
77+
"content": combined_content,
78+
"alignment": "right"
79+
})
80+
81+
else:
82+
# Create structured data for center alignment
83+
all_processed_lines.append({
84+
"content": visible_content,
85+
"alignment": "center"
86+
})
87+
88+
return all_processed_lines
89+
90+
def format_structured_for_display(structured_lines: list) -> str:
91+
"""Convert structured message data to display format with alignment markers"""
92+
if not structured_lines:
93+
return ""
94+
95+
formatted_lines = []
96+
for line_data in structured_lines:
97+
if isinstance(line_data, dict):
98+
alignment_prefix = {
99+
"left": "[ALIGN_LEFT]",
100+
"center": "[ALIGN_CENTER]",
101+
"right": "[ALIGN_RIGHT]"
102+
}
103+
prefix = alignment_prefix.get(line_data["alignment"], "[ALIGN_CENTER]")
104+
formatted_lines.append(f"{prefix}{line_data['content']}")
105+
else:
106+
# Handle any string fallbacks
107+
formatted_lines.append(f"[ALIGN_CENTER]{line_data}")
108+
109+
return "\n".join(formatted_lines)
110+
15111
def parse_dms_pages(raw: str) -> Tuple[str, str, str]:
16-
if not raw:
112+
# Ensure raw is a string and handle None or empty cases
113+
if not raw or raw is None:
17114
return "", "", ""
115+
text = str(raw) # Ensure string type
18116
text = raw
19117
# Remove control characters
20118
text = remove_control_characters(text)
21-
# Normalize newlines
22-
text = re.sub(r"\[nl\]", "\n", text, flags=re.IGNORECASE)
119+
23120
# Split pages BEFORE removing other tokens
24121
pages = re.split(r"\[np\]", text, flags=re.IGNORECASE)
25122
cleaned_pages = []
26123
for page in pages[:3]: # max 3 pages
27124
# Process justification tokens BEFORE removing other NTCIP tokens
28125
page = process_justification_tokens(page)
29126

30-
# Remove remaining NTCIP formatting tokens
31-
page = NTCIP_TOKEN_PATTERN.sub("", page)
32-
# Normalize whitespace (tabs to single space, multiple spaces to single space)
33-
page = re.sub(r"[ \t]+", " ", page)
127+
# Convert to structured data
128+
structured_lines = convert_message(page)
34129

35-
# Replace placeholder with spacing AFTER whitespace normalization
36-
page = page.replace('__JUSTIFY_RIGHT__', ' ') # 4 spaces for right justification
130+
# Convert structured data to display format with alignment markers
131+
display_format = format_structured_for_display(structured_lines)
132+
133+
# Remove remaining NTCIP formatting tokens
134+
display_format = NTCIP_TOKEN_PATTERN.sub("", display_format)
37135

38136
# Normalize excessive newlines
39-
page = re.sub(r"\n{3,}", "\n\n", page)
40-
cleaned_pages.append(page.strip())
137+
display_format = re.sub(r"\n{3,}", "\n\n", display_format)
138+
cleaned_pages.append(display_format.strip())
139+
41140
# Pad missing pages
42141
while len(cleaned_pages) < 3:
43142
cleaned_pages.append("")
@@ -53,30 +152,23 @@ def process_justification_tokens(page: str) -> str:
53152
processed = processed.replace('[jl4]', '__JUSTIFY_RIGHT__')
54153

55154
# Remove [jl2] tokens
56-
processed = processed.replace('[jl2]', '')
155+
processed = processed.replace('[jl2]', ' ')
57156

58157
return processed
59158

60159
def remove_control_characters(text: str) -> str:
61160
"""
62-
Remove control characters that appear after NTCIP formatting tokens.
161+
Remove control characters that appear after NTCIP formatting tokens
162+
or at the end of words.
63163
"""
64-
# Remove single lowercase letters that appear immediately after closing brackets
65-
# Pattern: ] followed by lowercase letter(s) at start of text content
66-
return re.sub(r'\][a-z](?=[A-Z])', ']', text)
67-
def parse_api_utc(dt_str):
68-
if not dt_str:
69-
return None
70-
71-
dt = parse_datetime(dt_str)
72-
73-
if dt is None:
74-
return None
75-
76-
if dt.tzinfo is None:
77-
dt = dt.replace(tzinfo=dt_timezone.utc)
164+
if not text or text is None:
165+
return ""
166+
167+
# Remove single lowercase letters after brackets: [nl]cTEXT -> [nl]TEXT
168+
# Remove trailing lowercase letters: TEXTt -> TEXT
169+
pattern = r'(?<=\])[a-z]|[a-z](?=\s|$)'
170+
return re.sub(pattern, '', text).strip(']')
78171

79-
return dt.astimezone(dt_timezone.utc)
80172

81173
class Dms(ExportModelOperationsMixin('dms'), BaseModel):
82174
id = models.CharField(primary_key=True, max_length=128, blank=True, default='')

src/frontend/src/Components/map/panels/DmsPanel.js

Lines changed: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// React
2-
import React, { useEffect, useContext } from 'react';
2+
import React, { useEffect } from 'react';
33

44
// Navigation
55
import { useSearchParams } from 'react-router-dom';
@@ -10,7 +10,6 @@ import Tooltip from 'react-bootstrap/Tooltip';
1010
import { isRestStopClosed } from '../../data/restStops';
1111
import DmsTypeIcon from '../DmsTypeIcon';
1212
import ShareURLButton from '../../shared/ShareURLButton';
13-
import { MapContext } from '../../../App';
1413
import FriendlyTime from '../../shared/FriendlyTime';
1514

1615
// Styling
@@ -36,27 +35,39 @@ export default function DmsPanel(props) {
3635

3736
const [searchParams, setSearchParams] = useSearchParams();
3837

39-
// Context
40-
const { mapContext } = useContext(MapContext);
41-
42-
43-
const displays = [
44-
{
45-
id: 1,
46-
status: dms.values_.message_display_1?.trim() ? 'Active' : 'No message',
47-
messages: [dms.values_.message_display_1]
48-
},
49-
{
50-
id: 2,
51-
status: dms.values_.message_display_2?.trim() ? 'Active' : 'No message',
52-
messages: [dms.values_.message_display_2]
53-
},
54-
{
55-
id: 3,
56-
status: dms.values_.message_display_3?.trim() ? 'Active' : 'No message',
57-
messages: [dms.values_.message_display_3]
38+
const parseMessageWithAlignment = (message) => {
39+
if (!message) return [];
40+
41+
const lines = message.split('\n');
42+
return lines.map(line => {
43+
// Extract alignment markers
44+
if (line.startsWith('[ALIGN_LEFT]')) {
45+
return { content: line.replace('[ALIGN_LEFT]', ''), alignment: 'left' };
46+
} else if (line.startsWith('[ALIGN_RIGHT]')) {
47+
return { content: line.replace('[ALIGN_RIGHT]', ''), alignment: 'right' };
48+
} else {
49+
return { content: line.replace('[ALIGN_CENTER]', ''), alignment: 'center' };
5850
}
59-
];
51+
}).filter(line => line.content.trim() !== ''); // Remove empty lines
52+
};
53+
54+
const displays = [
55+
{
56+
id: 1,
57+
status: dms.values_.message_display_1?.trim() ? 'Active' : 'No message',
58+
messages: parseMessageWithAlignment(dms.values_.message_display_1)
59+
},
60+
{
61+
id: 2,
62+
status: dms.values_.message_display_2?.trim() ? 'Active' : 'No message',
63+
messages: parseMessageWithAlignment(dms.values_.message_display_2)
64+
},
65+
{
66+
id: 3,
67+
status: dms.values_.message_display_3?.trim() ? 'Active' : 'No message',
68+
messages: parseMessageWithAlignment(dms.values_.message_display_3)
69+
}
70+
];
6071

6172
// useEffect hooks
6273
useEffect(() => {
@@ -106,12 +117,8 @@ export default function DmsPanel(props) {
106117
{display.messages && display.messages.length > 0 && display.messages[0] != "" && (
107118
<div className="black-board">
108119
{display.messages.map((line, index) => (
109-
<div key={index} className="message-line">
110-
{line}
111-
{/* This empty span is a common trick to ensure the
112-
justification triggers correctly on all browsers.
113-
*/}
114-
<span style={{ display: 'inline-block', width: '100%' }}></span>
120+
<div key={index} className={`message-line message-line--${line.alignment}`}>
121+
{line.content}
115122
</div>
116123
))}
117124
</div>

src/frontend/src/Components/map/panels/DmsPanel.scss

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
@import "../../../styles/variables";
2-
32
.popup {
43
// DMS panel
54
&--dms {
@@ -229,34 +228,21 @@
229228
font-family: 'Repetition Scrolling', monospace;
230229
font-style: normal;
231230
font-weight: 400;
232-
font-size: 24px;
231+
font-size: 22px;
233232
line-height: 30px;
234233
color: #F7CE46;
235234
text-transform: uppercase;
236-
letter-spacing: 0.02em;
237-
white-space: pre-line;
238-
239-
// 1. FULL WIDTH ALIGNMENT
240-
width: 100%;
241-
// display: block;
242-
text-align: center;
243-
// text-align-last: justify; // Forces "SUMAS X-ING" and "5 MIN" to opposite ends
235+
width: auto;
236+
overflow-wrap: break-word;
244237
margin: 0;
245-
246-
// 2. DOTTED ELECTRICAL EFFECT
247-
// We use a mask-image to create a grid of holes
248-
mask-image: radial-gradient(circle, black 50%, rgba(0, 0, 0, 0.2) 50%);
249-
mask-size: 2px 2px; // Adjust this to make dots bigger or smaller
250-
-webkit-mask-image: radial-gradient(circle, black 50%, rgba(0, 0, 0, 0.2) 55%);
251-
-webkit-mask-size: 3px 3px;
252-
253-
// 3. LED GLOW
254-
text-shadow: 0 0 2px rgba(247, 206, 70, 0.6);
238+
239+
// Alignment modifiers
240+
&--left { text-align: left; }
241+
&--center { text-align: center; }
242+
&--right { text-align: right; }
255243
}
256244

257245
}
258-
259-
260246
}
261247

262248
.sign-id-footer {
@@ -268,4 +254,4 @@
268254
text-align: left;
269255
}
270256
}
271-
}
257+
}
Binary file not shown.
Binary file not shown.

src/frontend/src/styles/typography.scss

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
@font-face {
2+
font-family: 'Repetition Scrolling';
3+
src: url("./fonts/repetition-scrolling/RepetitionScrolling.woff2") format("woff2"),
4+
url("./fonts/repetition-scrolling/RepetitionScrolling.woff") format("woff");
5+
font-weight: 400;
6+
font-style: normal;
7+
font-display: swap;
8+
}
9+
110
body, p, h1, h2, h3, h4, a, span {
211
font-family: 'BCSans', 'Noto Sans', Verdana, Arial, sans-serif;
312
-webkit-font-smoothing: antialiased;

0 commit comments

Comments
 (0)