mirror of
git://f0xx.org/android_cast
synced 2026-07-29 04:18:09 +03:00
next force snapshot
This commit is contained in:
@@ -58,6 +58,8 @@ public final class AppPreferences {
|
||||
private static final String KEY_DEV_COMMERCIAL_ADS = "dev_commercial_ads";
|
||||
private static final String KEY_DEV_COMMERCIAL_IAP = "dev_commercial_iap";
|
||||
private static final String KEY_DEV_COMMERCIAL_PLAY_STORE = "dev_commercial_play_store";
|
||||
private static final String KEY_DEV_DIAG_PING_PONG = "dev_diag_ping_pong";
|
||||
private static final String KEY_DEV_DIAG_PING_RESPONSE_MODE = "dev_diag_ping_response_mode";
|
||||
|
||||
/** Minimum interval between automatic OTA checks on app launch. */
|
||||
public static final long OTA_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L;
|
||||
@@ -386,6 +388,36 @@ public final class AppPreferences {
|
||||
prefs(context).edit().putBoolean(KEY_DEV_COMMERCIAL_PLAY_STORE, enabled).apply();
|
||||
}
|
||||
|
||||
/** Developer: ~1 Hz diagnostic cast ping-pong during sessions (default off). */
|
||||
public static boolean isDevDiagPingPongEnabled(Context context) {
|
||||
return prefs(context).getBoolean(KEY_DEV_DIAG_PING_PONG, false);
|
||||
}
|
||||
|
||||
public static void setDevDiagPingPongEnabled(Context context, boolean enabled) {
|
||||
prefs(context).edit().putBoolean(KEY_DEV_DIAG_PING_PONG, enabled).apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* How this device responds to peer diag probes:
|
||||
* 0 mirror-as-is, 1 mirror-smart (default), 2 reply-with.
|
||||
*/
|
||||
public static byte getDevDiagPingResponseMode(Context context) {
|
||||
int mode = prefs(context).getInt(KEY_DEV_DIAG_PING_RESPONSE_MODE,
|
||||
com.foxx.androidcast.network.diag.CastDiagPing.DIR_MIRROR_SMART);
|
||||
if (mode < 0 || mode > 2) {
|
||||
return com.foxx.androidcast.network.diag.CastDiagPing.DIR_MIRROR_SMART;
|
||||
}
|
||||
return (byte) mode;
|
||||
}
|
||||
|
||||
public static void setDevDiagPingResponseMode(Context context, byte mode) {
|
||||
int v = mode;
|
||||
if (v < 0 || v > 2) {
|
||||
v = com.foxx.androidcast.network.diag.CastDiagPing.DIR_MIRROR_SMART;
|
||||
}
|
||||
prefs(context).edit().putInt(KEY_DEV_DIAG_PING_RESPONSE_MODE, v).apply();
|
||||
}
|
||||
|
||||
public static CastSettings loadReceiverDefaults(Context context) {
|
||||
CastSettings s = new CastSettings();
|
||||
applyStoredCastSettings(context, PREFIX_RECEIVER, s);
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
|
||||
import com.foxx.androidcast.receiver.av.ReceiverVideoPreset;
|
||||
import com.foxx.androidcast.sender.SenderResolutionUiMode;
|
||||
import com.foxx.androidcast.dev.DevDiagnosticsController;
|
||||
import com.foxx.androidcast.network.diag.CastDiagPing;
|
||||
import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity;
|
||||
import com.google.android.material.tabs.TabLayout;
|
||||
import com.google.android.material.textfield.TextInputEditText;
|
||||
@@ -99,6 +100,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
});
|
||||
|
||||
bindAvPresets();
|
||||
bindDiagPingSection();
|
||||
bindSenderResolutionUiModeSection();
|
||||
bindWiredDisplaySection();
|
||||
bindCommercialSection();
|
||||
@@ -126,6 +128,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
findViewById(R.id.check_show_codec_priority_scores),
|
||||
findViewById(R.id.check_grab_session_stats),
|
||||
findViewById(R.id.check_receiver_adaptive_display));
|
||||
bindDiagPingSection();
|
||||
refreshOtaUi(
|
||||
findViewById(R.id.text_ota_installed_version),
|
||||
findViewById(R.id.edit_ota_manifest_url),
|
||||
@@ -136,6 +139,46 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
bindCommercialSection();
|
||||
}
|
||||
|
||||
private void bindDiagPingSection() {
|
||||
CheckBox enabled = findViewById(R.id.check_dev_diag_ping_pong);
|
||||
bindDeveloperCheckbox(enabled, AppPreferences.isDevDiagPingPongEnabled(this),
|
||||
AppPreferences::setDevDiagPingPongEnabled);
|
||||
Spinner spinner = findViewById(R.id.spinner_dev_diag_ping_response_mode);
|
||||
if (spinner == null) {
|
||||
return;
|
||||
}
|
||||
final byte[] modes = {
|
||||
CastDiagPing.DIR_MIRROR_AS_IS,
|
||||
CastDiagPing.DIR_MIRROR_SMART,
|
||||
CastDiagPing.DIR_REPLY_WITH
|
||||
};
|
||||
String[] labels = {
|
||||
getString(R.string.dev_diag_ping_response_mirror),
|
||||
getString(R.string.dev_diag_ping_response_smart),
|
||||
getString(R.string.dev_diag_ping_response_reply)
|
||||
};
|
||||
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
|
||||
android.R.layout.simple_spinner_item, labels);
|
||||
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
spinner.setAdapter(adapter);
|
||||
spinner.setOnItemSelectedListener(null);
|
||||
byte current = AppPreferences.getDevDiagPingResponseMode(this);
|
||||
int selection = 1;
|
||||
for (int i = 0; i < modes.length; i++) {
|
||||
if (modes[i] == current) {
|
||||
selection = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
spinner.setSelection(selection, false);
|
||||
spinner.setOnItemSelectedListener(simpleSelection(() -> {
|
||||
int pos = spinner.getSelectedItemPosition();
|
||||
if (pos >= 0 && pos < modes.length) {
|
||||
AppPreferences.setDevDiagPingResponseMode(this, modes[pos]);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void bindSenderResolutionUiModeSection() {
|
||||
Spinner spinner = findViewById(R.id.spinner_dev_sender_resolution_ui_mode);
|
||||
if (spinner == null) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.foxx.androidcast.media.CodecNegotiator;
|
||||
import com.foxx.androidcast.media.codec.CodecSessionRegistry;
|
||||
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
import com.foxx.androidcast.network.diag.DiagPingMetrics;
|
||||
import com.foxx.androidcast.receiver.StreamMetrics;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -140,6 +141,12 @@ public final class CastDiagnosticsFormatter {
|
||||
html.append("BW in: ").append(colored(bwInKbps > 0 ? bwInKbps + " kbps" : "n/a",
|
||||
suggestedBwKbps > 0 && bwInKbps > 0 && bwInKbps < suggestedBwKbps * 0.7f));
|
||||
html.append(" RTT: ").append(colored(rttMs > 0 ? rttMs + " ms" : "n/a", rttMs > 350));
|
||||
DiagPingMetrics.Snapshot diagPing = metrics.getDiagPing();
|
||||
if (diagPing.enabled && diagPing.pongsRx > 0) {
|
||||
int pingRtt = diagPing.avgRttMs > 0 ? diagPing.avgRttMs : diagPing.lastRttMs;
|
||||
html.append(" Diag ping: ")
|
||||
.append(colored(pingRtt + " ms avg (" + diagPing.pongsRx + ")", pingRtt > 350));
|
||||
}
|
||||
html.append("<br/>");
|
||||
|
||||
html.append("Suggested BW: ").append(colored(
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.network.control.NetworkControlAdvice;
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
import com.foxx.androidcast.network.diag.CastDiagPing;
|
||||
|
||||
import android.media.MediaFormat;
|
||||
|
||||
@@ -46,6 +47,11 @@ public final class CastProtocol {
|
||||
public static final byte MSG_NACK = 16;
|
||||
/** Receiver capability/settings (same payload as {@link #MSG_CAST_SETTINGS}). */
|
||||
public static final byte MSG_RECEIVER_CAST_SETTINGS = 17;
|
||||
/**
|
||||
* Diagnostic ping-pong (developer). Payload: {@link com.foxx.androidcast.network.diag.CastDiagPing}.
|
||||
* Peers on older builds ignore unknown types.
|
||||
*/
|
||||
public static final byte MSG_DIAG_PING = 18;
|
||||
/** UDP outer wrapper: payload is {@link CastPacketFramer} wire bytes. */
|
||||
public static final byte MSG_WIRE_PACKET = 127;
|
||||
|
||||
@@ -419,6 +425,14 @@ public final class CastProtocol {
|
||||
return c;
|
||||
}
|
||||
|
||||
public static byte[] diagPingPayload(CastDiagPing.Packet packet) throws IOException {
|
||||
return CastDiagPing.encode(packet);
|
||||
}
|
||||
|
||||
public static CastDiagPing.Packet parseDiagPing(byte[] payload) throws IOException {
|
||||
return CastDiagPing.decode(payload);
|
||||
}
|
||||
|
||||
public static byte[] audioConsentPayload(boolean accept) {
|
||||
return new byte[]{(byte) (accept ? 1 : 0)};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import android.util.Log;
|
||||
|
||||
import com.foxx.androidcast.network.CastProtocol;
|
||||
import com.foxx.androidcast.network.CastSession;
|
||||
import com.foxx.androidcast.network.diag.DiagPingSession;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@@ -25,6 +26,7 @@ public class NetworkFeedbackManager {
|
||||
private final NetworkControlPlane plane;
|
||||
private final CastSession session;
|
||||
private final boolean isSender;
|
||||
private DiagPingSession diagPing;
|
||||
|
||||
public NetworkFeedbackManager(CastSession session, boolean isSender, NetworkControlPlane plane) {
|
||||
this.session = session;
|
||||
@@ -40,9 +42,27 @@ public class NetworkFeedbackManager {
|
||||
return plane;
|
||||
}
|
||||
|
||||
public void configureDiagPing(boolean enabled, byte responseMode, DiagPingSession.SampleListener listener) {
|
||||
if (!enabled) {
|
||||
if (diagPing != null) {
|
||||
diagPing.setEnabled(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
diagPing = new DiagPingSession(session, responseMode, listener);
|
||||
diagPing.setEnabled(true);
|
||||
}
|
||||
|
||||
public DiagPingSession getDiagPingSession() {
|
||||
return diagPing;
|
||||
}
|
||||
|
||||
/** Call on each stream loop iteration (~500ms). */
|
||||
public void tick() {
|
||||
long now = System.currentTimeMillis();
|
||||
if (diagPing != null) {
|
||||
diagPing.tick(now);
|
||||
}
|
||||
NetworkControlAdvice advice = plane.tick(now);
|
||||
if (advice.sendStats) {
|
||||
sendStats(plane.buildLocalStats(now));
|
||||
@@ -53,6 +73,10 @@ public class NetworkFeedbackManager {
|
||||
}
|
||||
|
||||
public boolean handleMessage(CastProtocol.Message msg) {
|
||||
if (diagPing != null && diagPing.handleMessage(msg)) {
|
||||
applyDiagPingRtt();
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
switch (msg.type) {
|
||||
case CastProtocol.MSG_NETWORK_STATS:
|
||||
@@ -73,6 +97,15 @@ public class NetworkFeedbackManager {
|
||||
}
|
||||
}
|
||||
|
||||
private void applyDiagPingRtt() {
|
||||
if (diagPing == null || !(plane instanceof PassThroughNetworkControlPlane)) {
|
||||
return;
|
||||
}
|
||||
PassThroughNetworkControlPlane pass = (PassThroughNetworkControlPlane) plane;
|
||||
int rtt = diagPing.metrics().preferredRttMs(pass.getDiagPingRttMs());
|
||||
pass.setDiagPingRttMs(rtt);
|
||||
}
|
||||
|
||||
private void sendStats(NetworkStatsSnapshot stats) {
|
||||
try {
|
||||
session.send(CastProtocol.MSG_NETWORK_STATS, CastProtocol.networkStatsPayload(stats));
|
||||
|
||||
@@ -29,6 +29,7 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
|
||||
private long sendWindowBytes;
|
||||
private long sendWindowStartMs;
|
||||
private StatsEnricher statsEnricher;
|
||||
private volatile int diagPingRttMs;
|
||||
|
||||
/** Optional hook to attach encode/decode metrics before stats are sent. */
|
||||
public interface StatsEnricher {
|
||||
@@ -39,6 +40,16 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
|
||||
this.statsEnricher = enricher;
|
||||
}
|
||||
|
||||
public void setDiagPingRttMs(int rttMs) {
|
||||
if (rttMs > 0 && rttMs < 30_000) {
|
||||
diagPingRttMs = rttMs;
|
||||
}
|
||||
}
|
||||
|
||||
public int getDiagPingRttMs() {
|
||||
return diagPingRttMs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public NetworkControlAdvice tick(long nowMs) {
|
||||
outbound.sendStats = nowMs - lastStatsMs >= STATS_INTERVAL_MS;
|
||||
@@ -90,7 +101,11 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
|
||||
NetworkStatsSnapshot s = new NetworkStatsSnapshot();
|
||||
s.sequence = ++sequence;
|
||||
s.timestampMs = nowMs;
|
||||
if (diagPingRttMs > 0) {
|
||||
s.rttMs = diagPingRttMs;
|
||||
} else {
|
||||
s.rttMs = lastPeer != null ? lastPeer.rttMs : 0;
|
||||
}
|
||||
s.lossRatio = 0f;
|
||||
s.sendBitrateKbps = estimateSendKbps(nowMs);
|
||||
s.recvBitrateKbps = estimateRecvKbps(nowMs);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.foxx.androidcast.network.diag;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Diagnostic cast ping-pong wire format (optional; {@link com.foxx.androidcast.network.CastProtocol#MSG_DIAG_PING}).
|
||||
* <p>
|
||||
* v1 layout (big-endian):
|
||||
* magic(4) version(1) flags(1) direction(1) reserved(1)
|
||||
* timestamp_ms(8) seq(8) payload_size(4) [payload]
|
||||
* When {@link #FLAG_PONG} and direction {@link #DIR_REPLY_WITH}: reply_for_seq(8) reply_for_timestamp_ms(8)
|
||||
*/
|
||||
public final class CastDiagPing {
|
||||
public static final int MAGIC = 0x50494E47; // "PING"
|
||||
public static final byte VERSION = 1;
|
||||
|
||||
/** Set on pong packets. */
|
||||
public static final byte FLAG_PONG = 0x01;
|
||||
|
||||
/** Echo probe fields unchanged (dummy pong). */
|
||||
public static final byte DIR_MIRROR_AS_IS = 0;
|
||||
/** Pong uses local wall-clock timestamp; same seq as probe. */
|
||||
public static final byte DIR_MIRROR_SMART = 1;
|
||||
/** Pong includes reply-for fields plus local timestamp and seq. */
|
||||
public static final byte DIR_REPLY_WITH = 2;
|
||||
|
||||
private CastDiagPing() {}
|
||||
|
||||
public static final class Packet {
|
||||
public int magic;
|
||||
public byte version;
|
||||
public byte flags;
|
||||
public byte direction;
|
||||
public long timestampMs;
|
||||
public long seq;
|
||||
public byte[] payload = new byte[0];
|
||||
/** Valid when {@link #FLAG_PONG} and {@link #DIR_REPLY_WITH}. */
|
||||
public long replyForSeq;
|
||||
public long replyForTimestampMs;
|
||||
|
||||
public boolean isPong() {
|
||||
return (flags & FLAG_PONG) != 0;
|
||||
}
|
||||
|
||||
public boolean isValidMagic() {
|
||||
return magic == MAGIC && version == VERSION;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] encode(Packet p) throws IOException {
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream(32);
|
||||
DataOutputStream out = new DataOutputStream(bos);
|
||||
out.writeInt(p.magic != 0 ? p.magic : MAGIC);
|
||||
out.writeByte(VERSION);
|
||||
out.writeByte(p.flags);
|
||||
out.writeByte(p.direction);
|
||||
out.writeByte(0);
|
||||
out.writeLong(p.timestampMs);
|
||||
out.writeLong(p.seq);
|
||||
byte[] body = p.payload != null ? p.payload : new byte[0];
|
||||
out.writeInt(body.length);
|
||||
if (body.length > 0) {
|
||||
out.write(body);
|
||||
}
|
||||
if (p.isPong() && p.direction == DIR_REPLY_WITH) {
|
||||
out.writeLong(p.replyForSeq);
|
||||
out.writeLong(p.replyForTimestampMs);
|
||||
}
|
||||
out.flush();
|
||||
return bos.toByteArray();
|
||||
}
|
||||
|
||||
public static Packet decode(byte[] raw) throws IOException {
|
||||
if (raw == null || raw.length < 28) {
|
||||
throw new IOException("Diag ping too short");
|
||||
}
|
||||
DataInputStream in = new DataInputStream(new ByteArrayInputStream(raw));
|
||||
Packet p = new Packet();
|
||||
p.magic = in.readInt();
|
||||
p.version = in.readByte();
|
||||
p.flags = in.readByte();
|
||||
p.direction = in.readByte();
|
||||
in.readByte(); // reserved
|
||||
p.timestampMs = in.readLong();
|
||||
p.seq = in.readLong();
|
||||
int payloadSize = in.readInt();
|
||||
if (payloadSize < 0 || payloadSize > 64 * 1024) {
|
||||
throw new IOException("Invalid diag ping payload size: " + payloadSize);
|
||||
}
|
||||
if (payloadSize > 0) {
|
||||
p.payload = new byte[payloadSize];
|
||||
in.readFully(p.payload);
|
||||
}
|
||||
if (p.isPong() && p.direction == DIR_REPLY_WITH && in.available() >= 16) {
|
||||
p.replyForSeq = in.readLong();
|
||||
p.replyForTimestampMs = in.readLong();
|
||||
}
|
||||
return p;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.foxx.androidcast.network.diag;
|
||||
|
||||
/**
|
||||
* Rolling diagnostic ping RTT samples for overlays and bandwidth hints.
|
||||
*/
|
||||
public final class DiagPingMetrics {
|
||||
private static final int WINDOW = 16;
|
||||
|
||||
private final long[] rttMs = new long[WINDOW];
|
||||
private final long[] seq = new long[WINDOW];
|
||||
private int count;
|
||||
private int index;
|
||||
private long probesSent;
|
||||
private long pongsRx;
|
||||
private long lastRttMs;
|
||||
private long lastSeq;
|
||||
|
||||
public synchronized void recordProbeSent(long seqNum) {
|
||||
probesSent++;
|
||||
lastSeq = seqNum;
|
||||
}
|
||||
|
||||
public synchronized void recordPong(long rtt, long pongSeq) {
|
||||
if (rtt <= 0 || rtt > 30_000) {
|
||||
return;
|
||||
}
|
||||
pongsRx++;
|
||||
lastRttMs = rtt;
|
||||
lastSeq = pongSeq;
|
||||
rttMs[index] = rtt;
|
||||
seq[index] = pongSeq;
|
||||
index = (index + 1) % WINDOW;
|
||||
if (count < WINDOW) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized Snapshot snapshot() {
|
||||
Snapshot s = new Snapshot();
|
||||
s.enabled = true;
|
||||
s.probesSent = probesSent;
|
||||
s.pongsRx = pongsRx;
|
||||
s.lastRttMs = (int) lastRttMs;
|
||||
s.lastSeq = lastSeq;
|
||||
if (count == 0) {
|
||||
return s;
|
||||
}
|
||||
long sum = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
sum += rttMs[i];
|
||||
}
|
||||
s.avgRttMs = (int) (sum / count);
|
||||
return s;
|
||||
}
|
||||
|
||||
public synchronized int preferredRttMs(int fallbackRtt) {
|
||||
Snapshot s = snapshot();
|
||||
if (s.avgRttMs > 0) {
|
||||
return s.avgRttMs;
|
||||
}
|
||||
if (s.lastRttMs > 0) {
|
||||
return s.lastRttMs;
|
||||
}
|
||||
return fallbackRtt;
|
||||
}
|
||||
|
||||
public synchronized void reset() {
|
||||
count = 0;
|
||||
index = 0;
|
||||
probesSent = 0;
|
||||
pongsRx = 0;
|
||||
lastRttMs = 0;
|
||||
lastSeq = 0;
|
||||
}
|
||||
|
||||
public static final class Snapshot {
|
||||
public boolean enabled;
|
||||
public long probesSent;
|
||||
public long pongsRx;
|
||||
public int lastRttMs;
|
||||
public int avgRttMs;
|
||||
public long lastSeq;
|
||||
|
||||
public static final Snapshot EMPTY = new Snapshot();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.foxx.androidcast.network.diag;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import com.foxx.androidcast.network.CastProtocol;
|
||||
import com.foxx.androidcast.network.CastSession;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Optional ~1 Hz diagnostic ping-pong during an active cast (developer setting).
|
||||
* Unknown {@link CastProtocol#MSG_DIAG_PING} on old peers is ignored by their handlers.
|
||||
*/
|
||||
public final class DiagPingSession {
|
||||
private static final String TAG = "DiagPing";
|
||||
private static final long PROBE_INTERVAL_MS = 1_000L;
|
||||
|
||||
public interface SampleListener {
|
||||
void onDiagPingSample(DiagPingMetrics.Snapshot snapshot);
|
||||
}
|
||||
|
||||
private final CastSession session;
|
||||
private final byte localResponseMode;
|
||||
private final DiagPingMetrics metrics = new DiagPingMetrics();
|
||||
private final ConcurrentHashMap<Long, Long> pendingProbeSentMs = new ConcurrentHashMap<>();
|
||||
private final SampleListener listener;
|
||||
|
||||
private volatile boolean enabled;
|
||||
private long nextSeq;
|
||||
private long lastProbeMs;
|
||||
|
||||
public DiagPingSession(CastSession session, byte localResponseMode, SampleListener listener) {
|
||||
this.session = session;
|
||||
this.localResponseMode = localResponseMode;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean on) {
|
||||
enabled = on;
|
||||
if (!on) {
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public DiagPingMetrics metrics() {
|
||||
return metrics;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
nextSeq = 0;
|
||||
lastProbeMs = 0;
|
||||
pendingProbeSentMs.clear();
|
||||
metrics.reset();
|
||||
}
|
||||
|
||||
public void tick(long nowMs) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
if (lastProbeMs == 0 || nowMs - lastProbeMs >= PROBE_INTERVAL_MS) {
|
||||
sendProbe(nowMs);
|
||||
lastProbeMs = nowMs;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return true if consumed (including invalid magic). */
|
||||
public boolean handleMessage(CastProtocol.Message msg) {
|
||||
if (msg.type != CastProtocol.MSG_DIAG_PING) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
CastDiagPing.Packet p = CastProtocol.parseDiagPing(msg.payload);
|
||||
if (!p.isValidMagic()) {
|
||||
Log.w(TAG, "Ignoring diag ping with bad magic/version");
|
||||
return true;
|
||||
}
|
||||
if (p.isPong()) {
|
||||
onPong(p);
|
||||
} else {
|
||||
onProbe(p);
|
||||
}
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Diag ping parse failed", e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void onProbe(CastDiagPing.Packet probe) throws IOException {
|
||||
CastDiagPing.Packet pong = new CastDiagPing.Packet();
|
||||
pong.flags = CastDiagPing.FLAG_PONG;
|
||||
pong.direction = probe.direction;
|
||||
pong.payload = probe.payload != null ? probe.payload : new byte[0];
|
||||
switch (probe.direction) {
|
||||
case CastDiagPing.DIR_MIRROR_AS_IS:
|
||||
pong.timestampMs = probe.timestampMs;
|
||||
pong.seq = probe.seq;
|
||||
break;
|
||||
case CastDiagPing.DIR_MIRROR_SMART:
|
||||
pong.timestampMs = System.currentTimeMillis();
|
||||
pong.seq = probe.seq;
|
||||
break;
|
||||
case CastDiagPing.DIR_REPLY_WITH:
|
||||
pong.timestampMs = System.currentTimeMillis();
|
||||
pong.seq = nextSeq++;
|
||||
pong.replyForSeq = probe.seq;
|
||||
pong.replyForTimestampMs = probe.timestampMs;
|
||||
break;
|
||||
default:
|
||||
pong.direction = CastDiagPing.DIR_MIRROR_SMART;
|
||||
pong.timestampMs = System.currentTimeMillis();
|
||||
pong.seq = probe.seq;
|
||||
break;
|
||||
}
|
||||
session.send(CastProtocol.MSG_DIAG_PING, CastProtocol.diagPingPayload(pong));
|
||||
}
|
||||
|
||||
private void onPong(CastDiagPing.Packet pong) {
|
||||
long now = System.currentTimeMillis();
|
||||
long rtt;
|
||||
if (pong.direction == CastDiagPing.DIR_REPLY_WITH) {
|
||||
Long sentAt = pendingProbeSentMs.remove(pong.replyForSeq);
|
||||
rtt = sentAt != null ? now - sentAt : now - pong.replyForTimestampMs;
|
||||
} else {
|
||||
Long sentAt = pendingProbeSentMs.remove(pong.seq);
|
||||
rtt = sentAt != null ? now - sentAt : now - pong.timestampMs;
|
||||
}
|
||||
metrics.recordPong(rtt, pong.seq);
|
||||
notifyListener();
|
||||
}
|
||||
|
||||
private void sendProbe(long nowMs) {
|
||||
try {
|
||||
long seq = nextSeq++;
|
||||
pendingProbeSentMs.put(seq, nowMs);
|
||||
metrics.recordProbeSent(seq);
|
||||
CastDiagPing.Packet probe = new CastDiagPing.Packet();
|
||||
probe.timestampMs = nowMs;
|
||||
probe.seq = seq;
|
||||
probe.direction = localResponseMode;
|
||||
probe.payload = new byte[0];
|
||||
session.send(CastProtocol.MSG_DIAG_PING, CastProtocol.diagPingPayload(probe));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Send diag probe failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyListener() {
|
||||
if (listener != null) {
|
||||
listener.onDiagPingSample(metrics.snapshot());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -406,6 +406,11 @@ public class ReceiverCastService extends Service {
|
||||
public void onPeerNetworkStats(NetworkStatsSnapshot stats) {
|
||||
mainHandler.post(() -> streamMetrics.onPeerStats(stats));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDiagPingSample(com.foxx.androidcast.network.diag.DiagPingMetrics.Snapshot snapshot) {
|
||||
mainHandler.post(() -> streamMetrics.onDiagPingSample(snapshot));
|
||||
}
|
||||
}, this::enrichReceiverStats);
|
||||
session.start();
|
||||
updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase()));
|
||||
|
||||
@@ -15,6 +15,7 @@ import android.util.Log;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.foxx.androidcast.AppPreferences;
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.DeviceInfo;
|
||||
@@ -32,6 +33,7 @@ import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||
import com.foxx.androidcast.network.control.NetworkFeedbackManager;
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
import com.foxx.androidcast.network.diag.DiagPingMetrics;
|
||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -71,6 +73,10 @@ public class ReceiverSession {
|
||||
void onNetworkAdviceApplied();
|
||||
|
||||
void onPeerNetworkStats(NetworkStatsSnapshot stats);
|
||||
|
||||
/** Diagnostic ping-pong sample (developer setting). */
|
||||
default void onDiagPingSample(DiagPingMetrics.Snapshot snapshot) {
|
||||
}
|
||||
}
|
||||
|
||||
private final Context appContext;
|
||||
@@ -174,8 +180,14 @@ public class ReceiverSession {
|
||||
listener.onCastSessionStarting();
|
||||
networkFeedback = new NetworkFeedbackManager(session, false);
|
||||
if (networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
|
||||
((PassThroughNetworkControlPlane) networkFeedback.getPlane())
|
||||
.setStatsEnricher(statsEnricher);
|
||||
PassThroughNetworkControlPlane plane =
|
||||
(PassThroughNetworkControlPlane) networkFeedback.getPlane();
|
||||
plane.setStatsEnricher(statsEnricher);
|
||||
if (AppPreferences.isDevDiagPingPongEnabled(appContext)) {
|
||||
networkFeedback.configureDiagPing(true,
|
||||
AppPreferences.getDevDiagPingResponseMode(appContext),
|
||||
listener::onDiagPingSample);
|
||||
}
|
||||
}
|
||||
session.acceptClient();
|
||||
CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings);
|
||||
|
||||
@@ -12,6 +12,7 @@ package com.foxx.androidcast.receiver;
|
||||
* Digest: SHA256 d8a6a1d9253073899f2be582d7334435d8b5e69563656098aef7749f038234ab
|
||||
**********************************************************************/
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
import com.foxx.androidcast.network.diag.DiagPingMetrics;
|
||||
|
||||
/** Receiver-side counters for diagnostics overlay and bandwidth estimates. */
|
||||
public final class StreamMetrics {
|
||||
@@ -39,6 +40,7 @@ public final class StreamMetrics {
|
||||
private int peerRecvKbps;
|
||||
private int peerSendKbps;
|
||||
private NetworkStatsSnapshot lastPeer;
|
||||
private DiagPingMetrics.Snapshot diagPing = DiagPingMetrics.Snapshot.EMPTY;
|
||||
|
||||
public synchronized void reset() {
|
||||
videoPackets = 0;
|
||||
@@ -65,6 +67,20 @@ public final class StreamMetrics {
|
||||
peerRecvKbps = 0;
|
||||
peerSendKbps = 0;
|
||||
lastPeer = null;
|
||||
diagPing = DiagPingMetrics.Snapshot.EMPTY;
|
||||
}
|
||||
|
||||
public synchronized void onDiagPingSample(DiagPingMetrics.Snapshot sample) {
|
||||
diagPing = sample != null ? sample : DiagPingMetrics.Snapshot.EMPTY;
|
||||
if (diagPing.avgRttMs > 0) {
|
||||
peerRttMs = diagPing.avgRttMs;
|
||||
} else if (diagPing.lastRttMs > 0) {
|
||||
peerRttMs = diagPing.lastRttMs;
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized DiagPingMetrics.Snapshot getDiagPing() {
|
||||
return diagPing;
|
||||
}
|
||||
|
||||
public synchronized void onVideoPacket(boolean keyFrame, int bytes) {
|
||||
|
||||
@@ -278,6 +278,11 @@ public class ScreenCastService extends Service implements
|
||||
PassThroughNetworkControlPlane plane =
|
||||
(PassThroughNetworkControlPlane) networkFeedback.getPlane();
|
||||
plane.setStatsEnricher(this::enrichSenderStats);
|
||||
if (AppPreferences.isDevDiagPingPongEnabled(this)) {
|
||||
networkFeedback.configureDiagPing(true,
|
||||
AppPreferences.getDevDiagPingResponseMode(this),
|
||||
snapshot -> senderMetrics.onDiagPingSample(snapshot));
|
||||
}
|
||||
if (peers.size() > 1) {
|
||||
fanoutPump = new CastFanoutPump();
|
||||
fanoutPump.start(multiCoordinator.getSessions(), () -> stopping.set(true), plane);
|
||||
|
||||
@@ -11,6 +11,8 @@ package com.foxx.androidcast.sender;
|
||||
* - Cursor Agent (project assistant)
|
||||
* Digest: SHA256 65d468449f4cd236559457f2519730c3c07688bfe3082a961c081d6bd629d7f0
|
||||
**********************************************************************/
|
||||
import com.foxx.androidcast.network.diag.DiagPingMetrics;
|
||||
|
||||
/** Sender-side encode/send counters for notification diagnostics. */
|
||||
public final class SenderStreamMetrics {
|
||||
private long encodedFrames;
|
||||
@@ -20,6 +22,16 @@ public final class SenderStreamMetrics {
|
||||
private float lastSecondFps;
|
||||
private int lastSendKbps;
|
||||
private int peerRttMs;
|
||||
private DiagPingMetrics.Snapshot diagPing = DiagPingMetrics.Snapshot.EMPTY;
|
||||
|
||||
public synchronized void onDiagPingSample(DiagPingMetrics.Snapshot sample) {
|
||||
diagPing = sample != null ? sample : DiagPingMetrics.Snapshot.EMPTY;
|
||||
if (diagPing.avgRttMs > 0) {
|
||||
peerRttMs = diagPing.avgRttMs;
|
||||
} else if (diagPing.lastRttMs > 0) {
|
||||
peerRttMs = diagPing.lastRttMs;
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void onEncodedFrame(boolean keyFrame) {
|
||||
long now = System.currentTimeMillis();
|
||||
@@ -66,6 +78,9 @@ public final class SenderStreamMetrics {
|
||||
if (peerRttMs > 0) {
|
||||
sb.append(" · rtt ").append(peerRttMs).append("ms");
|
||||
}
|
||||
if (diagPing.enabled && diagPing.pongsRx > 0) {
|
||||
sb.append(" · ping ").append(diagPing.avgRttMs > 0 ? diagPing.avgRttMs : diagPing.lastRttMs).append("ms");
|
||||
}
|
||||
if (pump != null) {
|
||||
int dropped = pump.getDroppedPFrames();
|
||||
int q = pump.getQueueDepth();
|
||||
|
||||
@@ -30,6 +30,33 @@
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/dev_grab_session_stats" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/check_dev_diag_ping_pong"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/dev_diag_ping_pong" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/dev_diag_ping_response_mode"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinner_dev_diag_ping_response_mode"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/dev_diag_ping_hint"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/check_receiver_adaptive_display"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -250,6 +250,12 @@
|
||||
<string name="dev_show_receiver_debug_overlay">Show debug overlay on receiver</string>
|
||||
<string name="dev_show_codec_priority_scores">Show codec priority scores in settings</string>
|
||||
<string name="dev_grab_session_stats">Grab anonymous session stats</string>
|
||||
<string name="dev_diag_ping_pong">Diagnostic cast ping-pong (~1/s)</string>
|
||||
<string name="dev_diag_ping_response_mode">Ping response mode (this device)</string>
|
||||
<string name="dev_diag_ping_response_mirror">Mirror as-is (dummy pong)</string>
|
||||
<string name="dev_diag_ping_response_smart">Mirror smart (local timestamp)</string>
|
||||
<string name="dev_diag_ping_response_reply">Reply-with (seq + timestamps)</string>
|
||||
<string name="dev_diag_ping_hint">Requires both peers on a build that supports MSG_DIAG_PING. Old peers ignore unknown packets.</string>
|
||||
<string name="session_stats_title">Session statistics</string>
|
||||
<string name="session_stats_subtitle">Charts from session_send_* / session_recv_* JSON (enable grab in developer settings, then cast).</string>
|
||||
<string name="session_stats_open">Open statistics analyzer</string>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.foxx.androidcast.network.diag;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class CastDiagPingTest {
|
||||
|
||||
@Test
|
||||
public void roundTrip_probe() throws Exception {
|
||||
CastDiagPing.Packet in = new CastDiagPing.Packet();
|
||||
in.timestampMs = 1_700_000_000_123L;
|
||||
in.seq = 42;
|
||||
in.direction = CastDiagPing.DIR_MIRROR_SMART;
|
||||
in.payload = new byte[0];
|
||||
byte[] wire = CastDiagPing.encode(in);
|
||||
CastDiagPing.Packet out = CastDiagPing.decode(wire);
|
||||
assertTrue(out.isValidMagic());
|
||||
assertEquals(in.timestampMs, out.timestampMs);
|
||||
assertEquals(in.seq, out.seq);
|
||||
assertEquals(in.direction, out.direction);
|
||||
assertEquals(0, out.payload.length);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void roundTrip_replyWithPong() throws Exception {
|
||||
CastDiagPing.Packet in = new CastDiagPing.Packet();
|
||||
in.flags = CastDiagPing.FLAG_PONG;
|
||||
in.direction = CastDiagPing.DIR_REPLY_WITH;
|
||||
in.timestampMs = 1_700_000_000_500L;
|
||||
in.seq = 7;
|
||||
in.replyForSeq = 6;
|
||||
in.replyForTimestampMs = 1_700_000_000_400L;
|
||||
byte[] wire = CastDiagPing.encode(in);
|
||||
CastDiagPing.Packet out = CastDiagPing.decode(wire);
|
||||
assertTrue(out.isPong());
|
||||
assertEquals(CastDiagPing.DIR_REPLY_WITH, out.direction);
|
||||
assertEquals(in.replyForSeq, out.replyForSeq);
|
||||
assertEquals(in.replyForTimestampMs, out.replyForTimestampMs);
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,22 @@ Browser → apps.f0xx.org (Gentoo FE, TLS)
|
||||
|
||||
On FE, hub and (if you fix it) crashes should both target **`http://artc0.intra.raptor.org:80`**, same as `location /`.
|
||||
|
||||
## 1. `index.html`
|
||||
## 1. `index.html` + hub assets
|
||||
|
||||
```bash
|
||||
cp .../android_cast/examples/app_hub/index.html \
|
||||
.../htdocs/apps/app/androidcast_project/index.html
|
||||
cp .../android_cast/examples/app_hub/hub.css \
|
||||
.../htdocs/apps/app/androidcast_project/hub.css
|
||||
cp .../android_cast/examples/app_hub/assets/overtime-01-alphabet.svg \
|
||||
.../htdocs/apps/app/androidcast_project/assets/
|
||||
# GA4 (optional): set measurementId in analytics.config.js before copy
|
||||
cp .../android_cast/examples/app_hub/assets/analytics.config.js \
|
||||
.../htdocs/apps/app/androidcast_project/assets/
|
||||
```
|
||||
|
||||
Crash console ships `analytics.js` via `deploy-to-be-mount.sh` (under `crashes/assets/js/`).
|
||||
|
||||
## 2. BE nginx
|
||||
|
||||
Edit **`/etc/nginx/conf.d/apps.conf`** on Alpine (sshfs: `tmp/BE_alpine/etc/nginx/...`).
|
||||
|
||||
@@ -19,3 +19,12 @@ Deploy copies/symlink under `.../androidcast_project/` on the VM. Background wat
|
||||
|
||||
See **[DEPLOY.md](DEPLOY.md)** — edit `listen 80` in `tmp/BE_alpine/etc/nginx/conf.d/apps.conf` using
|
||||
`examples/crash_reporter/backend/nginx.apps-port80.fragment`.
|
||||
|
||||
## Google Analytics (optional)
|
||||
|
||||
Shared loader: `../crash_reporter/backend/public/assets/js/analytics.js`
|
||||
|
||||
1. Hub: copy `assets/analytics.config.example.js` → `assets/analytics.config.js` and set `measurementId: 'G-…'`.
|
||||
2. Crash console: in `config/config.php` set `analytics.enabled` and `analytics.measurement_id`.
|
||||
|
||||
Tracks hub page views, nav card clicks, and crash-console views (`console_view` by `data-view`).
|
||||
|
||||
7
examples/app_hub/assets/analytics.config.example.js
Normal file
7
examples/app_hub/assets/analytics.config.example.js
Normal file
@@ -0,0 +1,7 @@
|
||||
/** Copy to analytics.config.js on deploy and set your GA4 measurement ID. */
|
||||
window.ANDROIDCAST_ANALYTICS = {
|
||||
measurementId: 'G-XXXXXXXXXX',
|
||||
platform: 'hub',
|
||||
basePath: '/app/androidcast_project',
|
||||
debug: false
|
||||
};
|
||||
7
examples/app_hub/assets/analytics.config.js
Normal file
7
examples/app_hub/assets/analytics.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
/** Hub GA4 config — set measurementId to enable (see analytics.config.example.js). */
|
||||
window.ANDROIDCAST_ANALYTICS = {
|
||||
measurementId: '',
|
||||
platform: 'hub',
|
||||
basePath: '/app/androidcast_project',
|
||||
debug: false
|
||||
};
|
||||
366
examples/app_hub/assets/build_lcd_alphabet.py
Normal file
366
examples/app_hub/assets/build_lcd_alphabet.py
Normal file
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build full Overtime and Quartz 7-segment LCD SVG symbol sets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# Segment indices: 0=top, 1=UL, 2=UR, 3=mid, 4=LL, 5=LR, 6=bottom
|
||||
PATTERNS: dict[str, list[int]] = {
|
||||
" ": [],
|
||||
"0": [0, 1, 2, 4, 5, 6],
|
||||
"1": [2, 5],
|
||||
"2": [0, 1, 3, 5, 6],
|
||||
"3": [0, 2, 3, 5, 6],
|
||||
"4": [1, 2, 3, 5],
|
||||
"5": [0, 2, 3, 5, 6],
|
||||
"6": [0, 2, 3, 4, 5, 6],
|
||||
"7": [0, 1, 2],
|
||||
"8": [0, 1, 2, 3, 4, 5, 6],
|
||||
"9": [0, 1, 2, 3, 5, 6],
|
||||
"A": [0, 1, 2, 3, 4, 5],
|
||||
"B": [0, 1, 2, 3, 5, 6],
|
||||
"C": [0, 1, 4, 6],
|
||||
"D": [1, 2, 3, 5, 6],
|
||||
"E": [0, 3, 4, 5, 6],
|
||||
"F": [0, 3, 4, 5],
|
||||
"G": [0, 2, 4, 5, 6],
|
||||
"H": [1, 2, 3, 4, 5],
|
||||
"I": [0, 6],
|
||||
"J": [1, 2, 4, 5, 6],
|
||||
"K": [1, 3, 4, 5],
|
||||
"L": [4, 5, 6],
|
||||
"M": [0, 1, 2, 4, 5],
|
||||
"N": [1, 2, 4, 5],
|
||||
"O": [0, 1, 2, 4, 5, 6],
|
||||
"P": [0, 1, 2, 3, 4],
|
||||
"Q": [0, 1, 2, 4, 5, 6],
|
||||
"R": [0, 1, 2, 3, 4, 5],
|
||||
"S": [0, 2, 3, 5, 6],
|
||||
"T": [0, 3, 6],
|
||||
"U": [1, 2, 4, 5, 6],
|
||||
"V": [1, 4, 5],
|
||||
"W": [1, 2, 4, 5, 6],
|
||||
"X": [1, 2, 4, 5],
|
||||
"Y": [1, 2, 3],
|
||||
"Z": [0, 1, 4, 6],
|
||||
}
|
||||
|
||||
EXTRA_RECTS: dict[str, list[tuple]] = {
|
||||
"M": [
|
||||
("7.1", "5", "2.6", "13.2", 'transform="rotate(28 8.4 11.6)"'),
|
||||
("14.3", "5", "2.6", "13.2", 'transform="rotate(-28 15.6 11.6)"'),
|
||||
],
|
||||
"N": [
|
||||
("6.5", "6", "2.4", "12", 'transform="rotate(32 7.7 12)"'),
|
||||
("14.8", "14", "2.4", "12", 'transform="rotate(32 16 20)"'),
|
||||
],
|
||||
"K": [
|
||||
("10", "4", "2.2", "11", 'transform="rotate(-30 11.1 9.5)"'),
|
||||
("10", "17", "2.2", "11", 'transform="rotate(30 11.1 22.5)"'),
|
||||
],
|
||||
"V": [
|
||||
("7", "17", "2.2", "12", 'transform="rotate(24 8.1 23)"'),
|
||||
("12", "17", "2.2", "12", 'transform="rotate(-24 13.1 23)"'),
|
||||
],
|
||||
"W": [
|
||||
("5", "17", "2", "11", 'transform="rotate(18 6 22.5)"'),
|
||||
("9.5", "17", "2", "11", 'transform="rotate(-18 10.5 22.5)"'),
|
||||
("14", "17", "2", "11", 'transform="rotate(18 15 22.5)"'),
|
||||
],
|
||||
"X": [
|
||||
("7", "5", "2.2", "11", 'transform="rotate(32 8.1 10.5)"'),
|
||||
("11", "16", "2.2", "11", 'transform="rotate(-32 12.1 21.5)"'),
|
||||
],
|
||||
"Q": [("14", "18", "2.2", "10", 'transform="rotate(38 15.1 23)"')],
|
||||
"R": [("12", "17", "2.2", "10", 'transform="rotate(38 13.1 22)"')],
|
||||
"/": [
|
||||
("13", "4", "2.2", "10", 'transform="rotate(52 14.1 9)"'),
|
||||
("5", "18", "2.2", "10", 'transform="rotate(52 6.1 23)"'),
|
||||
],
|
||||
":": [("4", "10", "4", "4"), ("4", "20", "4", "4")],
|
||||
"+": [("6", "8", "4", "16"), ("2", "14", "12", "4")],
|
||||
"-": [("2", "14", "12", "4")],
|
||||
}
|
||||
|
||||
# Hand-tuned Overtime Bold shapes (from hub digit geometry).
|
||||
# Hand-tuned glyphs from hub (do not regenerate numerics).
|
||||
OVERTIME_VERBATIM: dict[str, tuple[int, str]] = {
|
||||
"0": (
|
||||
20,
|
||||
'<rect x="3" y="1" width="14" height="3" rx="1"/><rect x="16" y="4" width="3" height="11" rx="1"/>'
|
||||
'<rect x="16" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/>'
|
||||
'<rect x="1" y="17" width="3" height="11" rx="1"/><rect x="1" y="4" width="3" height="11" rx="1"/>',
|
||||
),
|
||||
"1": (20, '<rect x="16" y="4" width="3" height="11" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/>'),
|
||||
"2": (
|
||||
20,
|
||||
'<rect x="3" y="1" width="14" height="3" rx="1"/><rect x="1" y="4" width="3" height="11" rx="1"/>'
|
||||
'<rect x="3" y="15" width="14" height="3" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/>'
|
||||
'<rect x="3" y="28" width="14" height="3" rx="1"/>',
|
||||
),
|
||||
"3": (
|
||||
20,
|
||||
'<rect x="3" y="1" width="14" height="3" rx="1"/><rect x="16" y="4" width="3" height="11" rx="1"/>'
|
||||
'<rect x="3" y="15" width="14" height="3" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/>'
|
||||
'<rect x="3" y="28" width="14" height="3" rx="1"/>',
|
||||
),
|
||||
"4": (
|
||||
20,
|
||||
'<rect x="1" y="4" width="3" height="11" rx="1"/><rect x="3" y="15" width="14" height="3" rx="1"/>'
|
||||
'<rect x="16" y="4" width="3" height="11" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/>',
|
||||
),
|
||||
"5": (
|
||||
20,
|
||||
'<rect x="3" y="1" width="14" height="3" rx="1"/><rect x="1" y="4" width="3" height="11" rx="1"/>'
|
||||
'<rect x="3" y="15" width="14" height="3" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/>'
|
||||
'<rect x="3" y="28" width="14" height="3" rx="1"/>',
|
||||
),
|
||||
"6": (
|
||||
20,
|
||||
'<rect x="3" y="1" width="14" height="3" rx="1"/><rect x="1" y="4" width="3" height="11" rx="1"/>'
|
||||
'<rect x="3" y="15" width="14" height="3" rx="1"/><rect x="1" y="17" width="3" height="11" rx="1"/>'
|
||||
'<rect x="16" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/>',
|
||||
),
|
||||
"7": (
|
||||
20,
|
||||
'<rect x="3" y="1" width="14" height="3" rx="1"/><rect x="16" y="4" width="3" height="11" rx="1"/>'
|
||||
'<rect x="16" y="17" width="3" height="11" rx="1"/>',
|
||||
),
|
||||
"8": (
|
||||
20,
|
||||
'<rect x="3" y="1" width="14" height="3" rx="1"/><rect x="1" y="4" width="3" height="11" rx="1"/>'
|
||||
'<rect x="16" y="4" width="3" height="11" rx="1"/><rect x="3" y="15" width="14" height="3" rx="1"/>'
|
||||
'<rect x="1" y="17" width="3" height="11" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/>'
|
||||
'<rect x="3" y="28" width="14" height="3" rx="1"/>',
|
||||
),
|
||||
"9": (
|
||||
20,
|
||||
'<rect x="3" y="1" width="14" height="3" rx="1"/><rect x="1" y="4" width="3" height="11" rx="1"/>'
|
||||
'<rect x="3" y="15" width="14" height="3" rx="1"/><rect x="16" y="4" width="3" height="11" rx="1"/>'
|
||||
'<rect x="16" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/>',
|
||||
),
|
||||
":": (12, '<rect x="4" y="10" width="4" height="4" rx="1"/><rect x="4" y="20" width="4" height="4" rx="1"/>'),
|
||||
"+": (16, '<rect x="6" y="8" width="4" height="16" rx="1"/><rect x="2" y="14" width="12" height="4" rx="1"/>'),
|
||||
"-": (16, '<rect x="2" y="14" width="12" height="4" rx="1"/>'),
|
||||
" ": (8, ""),
|
||||
"/": (
|
||||
14,
|
||||
'<rect x="11" y="5" width="2.2" height="10" rx="1" transform="rotate(52 12.1 10)"/>'
|
||||
'<rect x="4" y="19" width="2.2" height="10" rx="1" transform="rotate(52 5.1 24)"/>',
|
||||
),
|
||||
}
|
||||
|
||||
OVERTIME_CUSTOM: dict[str, tuple[int, str]] = {
|
||||
"G": (
|
||||
20,
|
||||
'<rect x="3" y="1" width="14" height="3" rx="1"/><rect x="1" y="4" width="3" height="11" rx="1"/>'
|
||||
'<rect x="1" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/>'
|
||||
'<rect x="16" y="17" width="3" height="11" rx="1"/><rect x="10" y="15" width="9" height="3" rx="1"/>',
|
||||
),
|
||||
"M": (
|
||||
24,
|
||||
'<rect x="1" y="4" width="3" height="24" rx="1"/><rect x="20" y="4" width="3" height="24" rx="1"/>'
|
||||
'<rect x="7.1" y="5" width="2.6" height="13.2" rx="1" transform="rotate(28 8.4 11.6)"/>'
|
||||
'<rect x="14.3" y="5" width="2.6" height="13.2" rx="1" transform="rotate(-28 15.6 11.6)"/>',
|
||||
),
|
||||
"T": (
|
||||
20,
|
||||
'<rect x="3" y="1" width="14" height="3" rx="1"/><rect x="8" y="4" width="4" height="24" rx="1"/>',
|
||||
),
|
||||
"Y": (
|
||||
20,
|
||||
'<rect x="1" y="4" width="3" height="11" rx="1"/><rect x="16" y="4" width="3" height="11" rx="1"/>'
|
||||
'<rect x="8" y="15" width="4" height="14" rx="1"/>',
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FontStyle:
|
||||
prefix: str
|
||||
outfile: str
|
||||
comment: str
|
||||
h_pad: float
|
||||
top_y: float
|
||||
mid_y: float
|
||||
bot_y: float
|
||||
seg_h: float
|
||||
v_w: float
|
||||
v_h: float
|
||||
left_x: float
|
||||
right_x: float
|
||||
seg_w: float
|
||||
|
||||
|
||||
FONTS: dict[str, FontStyle] = {
|
||||
"overtime": FontStyle(
|
||||
prefix="ot",
|
||||
outfile="overtime-01-alphabet.svg",
|
||||
comment="Overtime Bold style 7-segment LCD (full alphabet)",
|
||||
h_pad=3.0,
|
||||
top_y=1.0,
|
||||
mid_y=15.0,
|
||||
bot_y=28.0,
|
||||
seg_h=3.0,
|
||||
v_w=3.0,
|
||||
v_h=11.0,
|
||||
left_x=1.0,
|
||||
right_x=16.0,
|
||||
seg_w=14.0,
|
||||
),
|
||||
"quartz": FontStyle(
|
||||
prefix="qz",
|
||||
outfile="quartz-alphabet.svg",
|
||||
comment="Quartz (TS) style 7-segment LCD (full alphabet)",
|
||||
h_pad=0.0,
|
||||
top_y=0.0,
|
||||
mid_y=0.0,
|
||||
bot_y=0.0,
|
||||
seg_h=0.0,
|
||||
v_w=0.0,
|
||||
v_h=0.0,
|
||||
left_x=0.0,
|
||||
right_x=0.0,
|
||||
seg_w=0.0,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _rect(x, y, width, height, extra: str = "") -> str:
|
||||
attrs = f'x="{x}" y="{y}" width="{width}" height="{height}" rx="1"'
|
||||
if extra:
|
||||
return f"<rect {attrs} {extra}/>"
|
||||
return f"<rect {attrs}/>"
|
||||
|
||||
|
||||
def _seg_defs(style: FontStyle, w: float, h: float) -> list[tuple[float, float, float, float]]:
|
||||
if style.prefix == "qz":
|
||||
h_pad = max(1.0, w * 0.12)
|
||||
top_y = max(1.0, h * 0.04)
|
||||
mid_y = h * 0.47
|
||||
bot_y = h * 0.86
|
||||
seg_w = max(6.0, w - h_pad * 2)
|
||||
seg_h = max(2.0, h * 0.09)
|
||||
v_w = max(2.0, w * 0.14)
|
||||
v_h = max(6.0, h * 0.33)
|
||||
left_x = max(1.0, h_pad * 0.45)
|
||||
right_x = max(left_x + v_w + 1, w - left_x - v_w)
|
||||
upper_y = top_y + seg_h
|
||||
lower_y = mid_y + seg_h * 0.6
|
||||
return [
|
||||
(h_pad, top_y, seg_w, seg_h),
|
||||
(left_x, upper_y, v_w, v_h),
|
||||
(right_x, upper_y, v_w, v_h),
|
||||
(h_pad, mid_y, seg_w, seg_h),
|
||||
(left_x, lower_y, v_w, v_h),
|
||||
(right_x, lower_y, v_w, v_h),
|
||||
(h_pad, bot_y, seg_w, seg_h),
|
||||
]
|
||||
|
||||
upper_y = style.top_y + style.seg_h
|
||||
lower_y = 17.0
|
||||
return [
|
||||
(style.h_pad, style.top_y, style.seg_w, style.seg_h),
|
||||
(style.left_x, upper_y, style.v_w, style.v_h),
|
||||
(style.right_x, upper_y, style.v_w, style.v_h),
|
||||
(style.h_pad, style.mid_y, style.seg_w, style.seg_h),
|
||||
(style.left_x, lower_y, style.v_w, style.v_h),
|
||||
(style.right_x, lower_y, style.v_w, style.v_h),
|
||||
(style.h_pad, style.bot_y, style.seg_w, style.seg_h),
|
||||
]
|
||||
|
||||
|
||||
def _symbol_id(prefix: str, ch: str) -> str:
|
||||
if ch == " ":
|
||||
return f"{prefix}-space"
|
||||
if ch == ":":
|
||||
return f"{prefix}-colon"
|
||||
if ch == "+":
|
||||
return f"{prefix}-plus"
|
||||
if ch == "-":
|
||||
return f"{prefix}-minus"
|
||||
if ch == "/":
|
||||
return f"{prefix}-slash"
|
||||
if len(ch) == 1 and "0" <= ch <= "9":
|
||||
return f"{prefix}-{ch}"
|
||||
if len(ch) == 1 and "A" <= ch <= "Z":
|
||||
return f"{prefix}-{ch}"
|
||||
return f"{prefix}-u{ord(ch):04x}"
|
||||
|
||||
|
||||
def _symbol_body(style: FontStyle, ch: str, width: int) -> str:
|
||||
if style.prefix == "ot" and ch in OVERTIME_VERBATIM:
|
||||
_, body = OVERTIME_VERBATIM[ch]
|
||||
return body
|
||||
if style.prefix == "ot" and ch in OVERTIME_CUSTOM:
|
||||
_, body = OVERTIME_CUSTOM[ch]
|
||||
return body
|
||||
|
||||
h = 32.0
|
||||
w = float(width)
|
||||
segs = PATTERNS.get(ch, [])
|
||||
parts: list[str] = []
|
||||
defs = _seg_defs(style, w, h)
|
||||
for idx in segs:
|
||||
if idx < 7:
|
||||
x, y, rw, rh = defs[idx]
|
||||
parts.append(_rect(f"{x:.2f}", f"{y:.2f}", f"{rw:.2f}", f"{rh:.2f}"))
|
||||
for item in EXTRA_RECTS.get(ch, []):
|
||||
if len(item) == 4:
|
||||
parts.append(_rect(*item))
|
||||
else:
|
||||
parts.append(_rect(item[0], item[1], item[2], item[3], item[4]))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _char_width(ch: str) -> int:
|
||||
if ch in "MWKNQRX":
|
||||
return 24
|
||||
if ch == ":":
|
||||
return 12
|
||||
if ch in "+-":
|
||||
return 16
|
||||
if ch == "/":
|
||||
return 14
|
||||
if ch == " ":
|
||||
return 8
|
||||
if ch in OVERTIME_VERBATIM:
|
||||
return OVERTIME_VERBATIM[ch][0]
|
||||
if ch in OVERTIME_CUSTOM:
|
||||
return OVERTIME_CUSTOM[ch][0]
|
||||
return 20
|
||||
|
||||
|
||||
def build_font(style: FontStyle, out_dir: Path) -> None:
|
||||
chars = (
|
||||
[" "] + [str(d) for d in range(10)] + [chr(c) for c in range(ord("A"), ord("Z") + 1)]
|
||||
+ [":", "+", "-", "/"]
|
||||
)
|
||||
lines = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" '
|
||||
'style="position:absolute;width:0;height:0;overflow:hidden">',
|
||||
f" <!-- {style.comment} -->",
|
||||
" <defs>",
|
||||
]
|
||||
for ch in chars:
|
||||
width = _char_width(ch)
|
||||
sid = _symbol_id(style.prefix, ch)
|
||||
body = _symbol_body(style, ch, width)
|
||||
lines.append(f' <symbol id="{sid}" viewBox="0 0 {width} 32">{body}</symbol>')
|
||||
lines.append(" </defs>")
|
||||
lines.append("</svg>")
|
||||
out_path = out_dir / style.outfile
|
||||
out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
print(f"Wrote {out_path} ({len(chars)} symbols)")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
out_dir = Path(__file__).resolve().parent
|
||||
for style in FONTS.values():
|
||||
build_font(style, out_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
195
examples/app_hub/assets/build_quartz_alphabet.py
Normal file
195
examples/app_hub/assets/build_quartz_alphabet.py
Normal file
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate Quartz-style 7-segment LCD symbol set for hub clock rows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Segment indices: 0=top, 1=upper-left, 2=upper-right, 3=mid, 4=lower-left, 5=lower-right, 6=bottom
|
||||
PATTERNS: dict[str, list[int]] = {
|
||||
" ": [],
|
||||
"0": [0, 1, 2, 4, 5, 6],
|
||||
"1": [2, 5],
|
||||
"2": [0, 1, 3, 4, 6],
|
||||
"3": [0, 1, 2, 3, 6],
|
||||
"4": [1, 2, 3, 5],
|
||||
"5": [0, 2, 3, 5, 6],
|
||||
"6": [0, 2, 3, 4, 5, 6],
|
||||
"7": [0, 1, 2],
|
||||
"8": [0, 1, 2, 3, 4, 5, 6],
|
||||
"9": [0, 1, 2, 3, 5, 6],
|
||||
"A": [0, 1, 2, 3, 4, 5],
|
||||
"B": [0, 1, 2, 3, 5, 6],
|
||||
"C": [0, 1, 4, 6],
|
||||
"D": [1, 2, 3, 5, 6],
|
||||
"E": [0, 3, 4, 5, 6],
|
||||
"F": [0, 3, 4, 5],
|
||||
"G": [0, 2, 4, 5, 6],
|
||||
"H": [1, 2, 3, 4, 5],
|
||||
"I": [0, 6],
|
||||
"J": [1, 2, 4, 5, 6],
|
||||
"K": [1, 3, 4, 5],
|
||||
"L": [4, 5, 6],
|
||||
"M": [0, 1, 2, 4, 5],
|
||||
"N": [1, 2, 4, 5],
|
||||
"O": [0, 1, 2, 4, 5, 6],
|
||||
"P": [0, 1, 2, 3, 4],
|
||||
"Q": [0, 1, 2, 4, 5, 6],
|
||||
"R": [0, 1, 2, 3, 4, 5],
|
||||
"S": [0, 2, 3, 5, 6],
|
||||
"T": [0, 3, 6],
|
||||
"U": [1, 2, 4, 5, 6],
|
||||
"V": [1, 4, 5],
|
||||
"W": [1, 2, 4, 5, 6],
|
||||
"X": [1, 2, 4, 5],
|
||||
"Y": [2, 3, 5, 6],
|
||||
"Z": [0, 1, 4, 6],
|
||||
":": [10, 11],
|
||||
"+": [12, 13],
|
||||
"-": [14],
|
||||
"/": [15, 16],
|
||||
}
|
||||
|
||||
EXTRA_RECTS: dict[str, list[tuple]] = {
|
||||
"M": [
|
||||
("7.1", "5", "2.6", "13.2", 'transform="rotate(28 8.4 11.6)"'),
|
||||
("14.3", "5", "2.6", "13.2", 'transform="rotate(-28 15.6 11.6)"'),
|
||||
],
|
||||
"N": [
|
||||
("6.5", "6", "2.4", "12", 'transform="rotate(32 7.7 12)"'),
|
||||
("14.8", "14", "2.4", "12", 'transform="rotate(32 16 20)"'),
|
||||
],
|
||||
"K": [
|
||||
("10", "4", "2.2", "11", 'transform="rotate(-30 11.1 9.5)"'),
|
||||
("10", "17", "2.2", "11", 'transform="rotate(30 11.1 22.5)"'),
|
||||
],
|
||||
"V": [
|
||||
("7", "17", "2.2", "12", 'transform="rotate(24 8.1 23)"'),
|
||||
("12", "17", "2.2", "12", 'transform="rotate(-24 13.1 23)"'),
|
||||
],
|
||||
"W": [
|
||||
("5", "17", "2", "11", 'transform="rotate(18 6 22.5)"'),
|
||||
("9.5", "17", "2", "11", 'transform="rotate(-18 10.5 22.5)"'),
|
||||
("14", "17", "2", "11", 'transform="rotate(18 15 22.5)"'),
|
||||
],
|
||||
"X": [
|
||||
("7", "5", "2.2", "11", 'transform="rotate(32 8.1 10.5)"'),
|
||||
("11", "16", "2.2", "11", 'transform="rotate(-32 12.1 21.5)"'),
|
||||
],
|
||||
"Q": [
|
||||
("14", "18", "2.2", "10", 'transform="rotate(38 15.1 23)"'),
|
||||
],
|
||||
"R": [
|
||||
("12", "17", "2.2", "10", 'transform="rotate(38 13.1 22)"'),
|
||||
],
|
||||
"/": [
|
||||
("13", "4", "2.2", "10", 'transform="rotate(52 14.1 9)"'),
|
||||
("5", "18", "2.2", "10", 'transform="rotate(52 6.1 23)"'),
|
||||
],
|
||||
":": [
|
||||
("4", "10", "4", "4"),
|
||||
("4", "20", "4", "4"),
|
||||
],
|
||||
"+": [
|
||||
("6", "8", "4", "16"),
|
||||
("2", "14", "12", "4"),
|
||||
],
|
||||
"-": [
|
||||
("2", "14", "12", "4"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _seg_defs(w: float, h: float) -> list[tuple[float, float, float, float]]:
|
||||
h_pad = max(1.0, w * 0.12)
|
||||
top_y = max(1.0, h * 0.04)
|
||||
mid_y = h * 0.47
|
||||
bot_y = h * 0.86
|
||||
seg_w = max(6.0, w - h_pad * 2)
|
||||
seg_h = max(2.0, h * 0.09)
|
||||
v_w = max(2.0, w * 0.14)
|
||||
v_h = max(6.0, h * 0.33)
|
||||
left_x = max(1.0, h_pad * 0.45)
|
||||
right_x = max(left_x + v_w + 1, w - left_x - v_w)
|
||||
upper_y = top_y + seg_h
|
||||
lower_y = mid_y + seg_h * 0.6
|
||||
return [
|
||||
(h_pad, top_y, seg_w, seg_h),
|
||||
(left_x, upper_y, v_w, v_h),
|
||||
(right_x, upper_y, v_w, v_h),
|
||||
(h_pad, mid_y, seg_w, seg_h),
|
||||
(left_x, lower_y, v_w, v_h),
|
||||
(right_x, lower_y, v_w, v_h),
|
||||
(h_pad, bot_y, seg_w, seg_h),
|
||||
]
|
||||
|
||||
|
||||
def _rect(x, y, width, height, extra: str = "") -> str:
|
||||
attrs = f'x="{x}" y="{y}" width="{width}" height="{height}" rx="1"'
|
||||
if extra:
|
||||
return f"<rect {attrs} {extra}/>"
|
||||
return f"<rect {attrs}/>"
|
||||
|
||||
|
||||
def symbol_body(ch: str, width: int = 20) -> str:
|
||||
h = 32.0
|
||||
w = float(width)
|
||||
segs = PATTERNS.get(ch, [])
|
||||
parts: list[str] = []
|
||||
defs = _seg_defs(w, h)
|
||||
for idx in segs:
|
||||
if idx < 7:
|
||||
x, y, rw, rh = defs[idx]
|
||||
parts.append(_rect(f"{x:.2f}", f"{y:.2f}", f"{rw:.2f}", f"{rh:.2f}"))
|
||||
for item in EXTRA_RECTS.get(ch, []):
|
||||
if len(item) == 4:
|
||||
parts.append(_rect(*item))
|
||||
else:
|
||||
parts.append(_rect(item[0], item[1], item[2], item[3], item[4]))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def symbol_id(ch: str) -> str:
|
||||
if ch == " ":
|
||||
return "qz-space"
|
||||
if ch == ":":
|
||||
return "qz-colon"
|
||||
if ch == "+":
|
||||
return "qz-plus"
|
||||
if ch == "-":
|
||||
return "qz-minus"
|
||||
if ch == "/":
|
||||
return "qz-slash"
|
||||
if len(ch) == 1 and "0" <= ch <= "9":
|
||||
return f"qz-{ch}"
|
||||
if len(ch) == 1 and "A" <= ch <= "Z":
|
||||
return f"qz-{ch}"
|
||||
return f"qz-u{ord(ch):04x}"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
chars = (
|
||||
[" "] + [str(d) for d in range(10)] + [chr(c) for c in range(ord("A"), ord("Z") + 1)]
|
||||
+ [":", "+", "-", "/"]
|
||||
)
|
||||
wide = set("MWKNQRX")
|
||||
lines = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" '
|
||||
'style="position:absolute;width:0;height:0;overflow:hidden">',
|
||||
" <!-- Quartz (TS) style 7-segment LCD glyphs for hub timezone/date rows -->",
|
||||
" <defs>",
|
||||
]
|
||||
for ch in chars:
|
||||
width = 24 if ch in wide else (12 if ch == ":" else (16 if ch in "+-" else (14 if ch == "/" else (8 if ch == " " else 20))))
|
||||
sid = symbol_id(ch)
|
||||
body = symbol_body(ch, width)
|
||||
lines.append(f' <symbol id="{sid}" viewBox="0 0 {width} 32">{body}</symbol>')
|
||||
lines.append(" </defs>")
|
||||
lines.append("</svg>")
|
||||
out = "\n".join(lines) + "\n"
|
||||
path = __file__.replace("build_quartz_alphabet.py", "quartz-alphabet.svg")
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(out)
|
||||
print(f"Wrote {path} ({len(chars)} symbols)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,9 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="position:absolute;width:0;height:0;overflow:hidden">
|
||||
<!-- Overtime 01 alphabet: 7-segment LCD glyphs for hub primary clock -->
|
||||
<!-- Overtime Bold style 7-segment LCD (full alphabet) -->
|
||||
<defs>
|
||||
<symbol id="ot-space" viewBox="0 0 8 32"></symbol>
|
||||
<symbol id="ot-0" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="16" y="4" width="3" height="11" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/><rect x="1" y="17" width="3" height="11" rx="1"/><rect x="1" y="4" width="3" height="11" rx="1"/></symbol>
|
||||
<symbol id="ot-1" viewBox="0 0 20 32"><rect x="16" y="4" width="3" height="11" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/></symbol>
|
||||
<symbol id="ot-2" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="16" y="4" width="3" height="11" rx="1"/><rect x="3" y="15" width="14" height="3" rx="1"/><rect x="1" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/></symbol>
|
||||
<symbol id="ot-2" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="1" y="4" width="3" height="11" rx="1"/><rect x="3" y="15" width="14" height="3" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/></symbol>
|
||||
<symbol id="ot-3" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="16" y="4" width="3" height="11" rx="1"/><rect x="3" y="15" width="14" height="3" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/></symbol>
|
||||
<symbol id="ot-4" viewBox="0 0 20 32"><rect x="1" y="4" width="3" height="11" rx="1"/><rect x="3" y="15" width="14" height="3" rx="1"/><rect x="16" y="4" width="3" height="11" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/></symbol>
|
||||
<symbol id="ot-5" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="1" y="4" width="3" height="11" rx="1"/><rect x="3" y="15" width="14" height="3" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/></symbol>
|
||||
@@ -11,12 +12,35 @@
|
||||
<symbol id="ot-7" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="16" y="4" width="3" height="11" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/></symbol>
|
||||
<symbol id="ot-8" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="1" y="4" width="3" height="11" rx="1"/><rect x="16" y="4" width="3" height="11" rx="1"/><rect x="3" y="15" width="14" height="3" rx="1"/><rect x="1" y="17" width="3" height="11" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/></symbol>
|
||||
<symbol id="ot-9" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="1" y="4" width="3" height="11" rx="1"/><rect x="3" y="15" width="14" height="3" rx="1"/><rect x="16" y="4" width="3" height="11" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/></symbol>
|
||||
<symbol id="ot-A" viewBox="0 0 20 32"><rect x="3.00" y="1.00" width="14.00" height="3.00" rx="1"/><rect x="1.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="3.00" y="15.00" width="14.00" height="3.00" rx="1"/><rect x="1.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="17.00" width="3.00" height="11.00" rx="1"/></symbol>
|
||||
<symbol id="ot-B" viewBox="0 0 20 32"><rect x="3.00" y="1.00" width="14.00" height="3.00" rx="1"/><rect x="1.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="3.00" y="15.00" width="14.00" height="3.00" rx="1"/><rect x="16.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="3.00" y="28.00" width="14.00" height="3.00" rx="1"/></symbol>
|
||||
<symbol id="ot-C" viewBox="0 0 20 32"><rect x="3.00" y="1.00" width="14.00" height="3.00" rx="1"/><rect x="1.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="1.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="3.00" y="28.00" width="14.00" height="3.00" rx="1"/></symbol>
|
||||
<symbol id="ot-D" viewBox="0 0 20 32"><rect x="1.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="3.00" y="15.00" width="14.00" height="3.00" rx="1"/><rect x="16.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="3.00" y="28.00" width="14.00" height="3.00" rx="1"/></symbol>
|
||||
<symbol id="ot-E" viewBox="0 0 20 32"><rect x="3.00" y="1.00" width="14.00" height="3.00" rx="1"/><rect x="3.00" y="15.00" width="14.00" height="3.00" rx="1"/><rect x="1.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="3.00" y="28.00" width="14.00" height="3.00" rx="1"/></symbol>
|
||||
<symbol id="ot-F" viewBox="0 0 20 32"><rect x="3.00" y="1.00" width="14.00" height="3.00" rx="1"/><rect x="3.00" y="15.00" width="14.00" height="3.00" rx="1"/><rect x="1.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="17.00" width="3.00" height="11.00" rx="1"/></symbol>
|
||||
<symbol id="ot-G" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="1" y="4" width="3" height="11" rx="1"/><rect x="1" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/><rect x="10" y="15" width="9" height="3" rx="1"/></symbol>
|
||||
<symbol id="ot-H" viewBox="0 0 20 32"><rect x="1.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="3.00" y="15.00" width="14.00" height="3.00" rx="1"/><rect x="1.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="17.00" width="3.00" height="11.00" rx="1"/></symbol>
|
||||
<symbol id="ot-I" viewBox="0 0 20 32"><rect x="3.00" y="1.00" width="14.00" height="3.00" rx="1"/><rect x="3.00" y="28.00" width="14.00" height="3.00" rx="1"/></symbol>
|
||||
<symbol id="ot-J" viewBox="0 0 20 32"><rect x="1.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="1.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="3.00" y="28.00" width="14.00" height="3.00" rx="1"/></symbol>
|
||||
<symbol id="ot-K" viewBox="0 0 24 32"><rect x="1.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="3.00" y="15.00" width="14.00" height="3.00" rx="1"/><rect x="1.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="10" y="4" width="2.2" height="11" rx="1" transform="rotate(-30 11.1 9.5)"/><rect x="10" y="17" width="2.2" height="11" rx="1" transform="rotate(30 11.1 22.5)"/></symbol>
|
||||
<symbol id="ot-L" viewBox="0 0 20 32"><rect x="1.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="3.00" y="28.00" width="14.00" height="3.00" rx="1"/></symbol>
|
||||
<symbol id="ot-M" viewBox="0 0 24 32"><rect x="1" y="4" width="3" height="24" rx="1"/><rect x="20" y="4" width="3" height="24" rx="1"/><rect x="7.1" y="5" width="2.6" height="13.2" rx="1" transform="rotate(28 8.4 11.6)"/><rect x="14.3" y="5" width="2.6" height="13.2" rx="1" transform="rotate(-28 15.6 11.6)"/></symbol>
|
||||
<symbol id="ot-N" viewBox="0 0 24 32"><rect x="1.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="1.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="6.5" y="6" width="2.4" height="12" rx="1" transform="rotate(32 7.7 12)"/><rect x="14.8" y="14" width="2.4" height="12" rx="1" transform="rotate(32 16 20)"/></symbol>
|
||||
<symbol id="ot-O" viewBox="0 0 20 32"><rect x="3.00" y="1.00" width="14.00" height="3.00" rx="1"/><rect x="1.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="1.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="3.00" y="28.00" width="14.00" height="3.00" rx="1"/></symbol>
|
||||
<symbol id="ot-P" viewBox="0 0 20 32"><rect x="3.00" y="1.00" width="14.00" height="3.00" rx="1"/><rect x="1.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="3.00" y="15.00" width="14.00" height="3.00" rx="1"/><rect x="1.00" y="17.00" width="3.00" height="11.00" rx="1"/></symbol>
|
||||
<symbol id="ot-Q" viewBox="0 0 24 32"><rect x="3.00" y="1.00" width="14.00" height="3.00" rx="1"/><rect x="1.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="1.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="3.00" y="28.00" width="14.00" height="3.00" rx="1"/><rect x="14" y="18" width="2.2" height="10" rx="1" transform="rotate(38 15.1 23)"/></symbol>
|
||||
<symbol id="ot-R" viewBox="0 0 24 32"><rect x="3.00" y="1.00" width="14.00" height="3.00" rx="1"/><rect x="1.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="3.00" y="15.00" width="14.00" height="3.00" rx="1"/><rect x="1.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="12" y="17" width="2.2" height="10" rx="1" transform="rotate(38 13.1 22)"/></symbol>
|
||||
<symbol id="ot-S" viewBox="0 0 20 32"><rect x="3.00" y="1.00" width="14.00" height="3.00" rx="1"/><rect x="16.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="3.00" y="15.00" width="14.00" height="3.00" rx="1"/><rect x="16.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="3.00" y="28.00" width="14.00" height="3.00" rx="1"/></symbol>
|
||||
<symbol id="ot-T" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="8" y="4" width="4" height="24" rx="1"/></symbol>
|
||||
<symbol id="ot-U" viewBox="0 0 20 32"><rect x="1.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="1.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="3.00" y="28.00" width="14.00" height="3.00" rx="1"/></symbol>
|
||||
<symbol id="ot-V" viewBox="0 0 20 32"><rect x="1.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="1.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="7" y="17" width="2.2" height="12" rx="1" transform="rotate(24 8.1 23)"/><rect x="12" y="17" width="2.2" height="12" rx="1" transform="rotate(-24 13.1 23)"/></symbol>
|
||||
<symbol id="ot-W" viewBox="0 0 24 32"><rect x="1.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="1.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="3.00" y="28.00" width="14.00" height="3.00" rx="1"/><rect x="5" y="17" width="2" height="11" rx="1" transform="rotate(18 6 22.5)"/><rect x="9.5" y="17" width="2" height="11" rx="1" transform="rotate(-18 10.5 22.5)"/><rect x="14" y="17" width="2" height="11" rx="1" transform="rotate(18 15 22.5)"/></symbol>
|
||||
<symbol id="ot-X" viewBox="0 0 24 32"><rect x="1.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="1.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="16.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="7" y="5" width="2.2" height="11" rx="1" transform="rotate(32 8.1 10.5)"/><rect x="11" y="16" width="2.2" height="11" rx="1" transform="rotate(-32 12.1 21.5)"/></symbol>
|
||||
<symbol id="ot-Y" viewBox="0 0 20 32"><rect x="1" y="4" width="3" height="11" rx="1"/><rect x="16" y="4" width="3" height="11" rx="1"/><rect x="8" y="15" width="4" height="14" rx="1"/></symbol>
|
||||
<symbol id="ot-Z" viewBox="0 0 20 32"><rect x="3.00" y="1.00" width="14.00" height="3.00" rx="1"/><rect x="1.00" y="4.00" width="3.00" height="11.00" rx="1"/><rect x="1.00" y="17.00" width="3.00" height="11.00" rx="1"/><rect x="3.00" y="28.00" width="14.00" height="3.00" rx="1"/></symbol>
|
||||
<symbol id="ot-colon" viewBox="0 0 12 32"><rect x="4" y="10" width="4" height="4" rx="1"/><rect x="4" y="20" width="4" height="4" rx="1"/></symbol>
|
||||
<symbol id="ot-plus" viewBox="0 0 16 32"><rect x="6" y="8" width="4" height="16" rx="1"/><rect x="2" y="14" width="12" height="4" rx="1"/></symbol>
|
||||
<symbol id="ot-minus" viewBox="0 0 16 32"><rect x="2" y="14" width="12" height="4" rx="1"/></symbol>
|
||||
<symbol id="ot-space" viewBox="0 0 8 32"></symbol>
|
||||
<symbol id="ot-G" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="1" y="4" width="3" height="11" rx="1"/><rect x="1" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/><rect x="10" y="15" width="9" height="3" rx="1"/></symbol>
|
||||
<symbol id="ot-M" viewBox="0 0 24 32"><rect x="1" y="4" width="3" height="24" rx="1"/><rect x="20" y="4" width="3" height="24" rx="1"/><rect x="7.1" y="5" width="2.6" height="13.2" rx="1" transform="rotate(28 8.4 11.6)"/><rect x="14.3" y="5" width="2.6" height="13.2" rx="1" transform="rotate(-28 15.6 11.6)"/></symbol>
|
||||
<symbol id="ot-T" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="8" y="4" width="4" height="24" rx="1"/></symbol>
|
||||
<symbol id="ot-slash" viewBox="0 0 14 32"><rect x="11" y="5" width="2.2" height="10" rx="1" transform="rotate(52 12.1 10)"/><rect x="4" y="19" width="2.2" height="10" rx="1" transform="rotate(52 5.1 24)"/></symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 13 KiB |
46
examples/app_hub/assets/quartz-alphabet.svg
Normal file
46
examples/app_hub/assets/quartz-alphabet.svg
Normal file
@@ -0,0 +1,46 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="position:absolute;width:0;height:0;overflow:hidden">
|
||||
<!-- Quartz (TS) style 7-segment LCD (full alphabet) -->
|
||||
<defs>
|
||||
<symbol id="qz-space" viewBox="0 0 8 32"></symbol>
|
||||
<symbol id="qz-0" viewBox="0 0 20 32"><rect x="2.40" y="1.28" width="15.20" height="2.88" rx="1"/><rect x="1.08" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="1.08" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="27.52" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-1" viewBox="0 0 20 32"><rect x="16.12" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/></symbol>
|
||||
<symbol id="qz-2" viewBox="0 0 20 32"><rect x="2.40" y="1.28" width="15.20" height="2.88" rx="1"/><rect x="1.08" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="15.04" width="15.20" height="2.88" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="27.52" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-3" viewBox="0 0 20 32"><rect x="2.40" y="1.28" width="15.20" height="2.88" rx="1"/><rect x="16.12" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="15.04" width="15.20" height="2.88" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="27.52" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-4" viewBox="0 0 20 32"><rect x="1.08" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="15.04" width="15.20" height="2.88" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/></symbol>
|
||||
<symbol id="qz-5" viewBox="0 0 20 32"><rect x="2.40" y="1.28" width="15.20" height="2.88" rx="1"/><rect x="16.12" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="15.04" width="15.20" height="2.88" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="27.52" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-6" viewBox="0 0 20 32"><rect x="2.40" y="1.28" width="15.20" height="2.88" rx="1"/><rect x="16.12" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="15.04" width="15.20" height="2.88" rx="1"/><rect x="1.08" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="27.52" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-7" viewBox="0 0 20 32"><rect x="2.40" y="1.28" width="15.20" height="2.88" rx="1"/><rect x="1.08" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="4.16" width="2.80" height="10.56" rx="1"/></symbol>
|
||||
<symbol id="qz-8" viewBox="0 0 20 32"><rect x="2.40" y="1.28" width="15.20" height="2.88" rx="1"/><rect x="1.08" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="15.04" width="15.20" height="2.88" rx="1"/><rect x="1.08" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="27.52" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-9" viewBox="0 0 20 32"><rect x="2.40" y="1.28" width="15.20" height="2.88" rx="1"/><rect x="1.08" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="15.04" width="15.20" height="2.88" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="27.52" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-A" viewBox="0 0 20 32"><rect x="2.40" y="1.28" width="15.20" height="2.88" rx="1"/><rect x="1.08" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="15.04" width="15.20" height="2.88" rx="1"/><rect x="1.08" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/></symbol>
|
||||
<symbol id="qz-B" viewBox="0 0 20 32"><rect x="2.40" y="1.28" width="15.20" height="2.88" rx="1"/><rect x="1.08" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="15.04" width="15.20" height="2.88" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="27.52" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-C" viewBox="0 0 20 32"><rect x="2.40" y="1.28" width="15.20" height="2.88" rx="1"/><rect x="1.08" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="1.08" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="27.52" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-D" viewBox="0 0 20 32"><rect x="1.08" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="15.04" width="15.20" height="2.88" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="27.52" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-E" viewBox="0 0 20 32"><rect x="2.40" y="1.28" width="15.20" height="2.88" rx="1"/><rect x="2.40" y="15.04" width="15.20" height="2.88" rx="1"/><rect x="1.08" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="27.52" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-F" viewBox="0 0 20 32"><rect x="2.40" y="1.28" width="15.20" height="2.88" rx="1"/><rect x="2.40" y="15.04" width="15.20" height="2.88" rx="1"/><rect x="1.08" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/></symbol>
|
||||
<symbol id="qz-G" viewBox="0 0 20 32"><rect x="2.40" y="1.28" width="15.20" height="2.88" rx="1"/><rect x="16.12" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="1.08" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="27.52" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-H" viewBox="0 0 20 32"><rect x="1.08" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="15.04" width="15.20" height="2.88" rx="1"/><rect x="1.08" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/></symbol>
|
||||
<symbol id="qz-I" viewBox="0 0 20 32"><rect x="2.40" y="1.28" width="15.20" height="2.88" rx="1"/><rect x="2.40" y="27.52" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-J" viewBox="0 0 20 32"><rect x="1.08" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="1.08" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="27.52" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-K" viewBox="0 0 24 32"><rect x="1.30" y="4.16" width="3.36" height="10.56" rx="1"/><rect x="2.88" y="15.04" width="18.24" height="2.88" rx="1"/><rect x="1.30" y="16.77" width="3.36" height="10.56" rx="1"/><rect x="19.34" y="16.77" width="3.36" height="10.56" rx="1"/><rect x="10" y="4" width="2.2" height="11" rx="1" transform="rotate(-30 11.1 9.5)"/><rect x="10" y="17" width="2.2" height="11" rx="1" transform="rotate(30 11.1 22.5)"/></symbol>
|
||||
<symbol id="qz-L" viewBox="0 0 20 32"><rect x="1.08" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="27.52" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-M" viewBox="0 0 24 32"><rect x="2.88" y="1.28" width="18.24" height="2.88" rx="1"/><rect x="1.30" y="4.16" width="3.36" height="10.56" rx="1"/><rect x="19.34" y="4.16" width="3.36" height="10.56" rx="1"/><rect x="1.30" y="16.77" width="3.36" height="10.56" rx="1"/><rect x="19.34" y="16.77" width="3.36" height="10.56" rx="1"/><rect x="7.1" y="5" width="2.6" height="13.2" rx="1" transform="rotate(28 8.4 11.6)"/><rect x="14.3" y="5" width="2.6" height="13.2" rx="1" transform="rotate(-28 15.6 11.6)"/></symbol>
|
||||
<symbol id="qz-N" viewBox="0 0 24 32"><rect x="1.30" y="4.16" width="3.36" height="10.56" rx="1"/><rect x="19.34" y="4.16" width="3.36" height="10.56" rx="1"/><rect x="1.30" y="16.77" width="3.36" height="10.56" rx="1"/><rect x="19.34" y="16.77" width="3.36" height="10.56" rx="1"/><rect x="6.5" y="6" width="2.4" height="12" rx="1" transform="rotate(32 7.7 12)"/><rect x="14.8" y="14" width="2.4" height="12" rx="1" transform="rotate(32 16 20)"/></symbol>
|
||||
<symbol id="qz-O" viewBox="0 0 20 32"><rect x="2.40" y="1.28" width="15.20" height="2.88" rx="1"/><rect x="1.08" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="1.08" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="27.52" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-P" viewBox="0 0 20 32"><rect x="2.40" y="1.28" width="15.20" height="2.88" rx="1"/><rect x="1.08" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="15.04" width="15.20" height="2.88" rx="1"/><rect x="1.08" y="16.77" width="2.80" height="10.56" rx="1"/></symbol>
|
||||
<symbol id="qz-Q" viewBox="0 0 24 32"><rect x="2.88" y="1.28" width="18.24" height="2.88" rx="1"/><rect x="1.30" y="4.16" width="3.36" height="10.56" rx="1"/><rect x="19.34" y="4.16" width="3.36" height="10.56" rx="1"/><rect x="1.30" y="16.77" width="3.36" height="10.56" rx="1"/><rect x="19.34" y="16.77" width="3.36" height="10.56" rx="1"/><rect x="2.88" y="27.52" width="18.24" height="2.88" rx="1"/><rect x="14" y="18" width="2.2" height="10" rx="1" transform="rotate(38 15.1 23)"/></symbol>
|
||||
<symbol id="qz-R" viewBox="0 0 24 32"><rect x="2.88" y="1.28" width="18.24" height="2.88" rx="1"/><rect x="1.30" y="4.16" width="3.36" height="10.56" rx="1"/><rect x="19.34" y="4.16" width="3.36" height="10.56" rx="1"/><rect x="2.88" y="15.04" width="18.24" height="2.88" rx="1"/><rect x="1.30" y="16.77" width="3.36" height="10.56" rx="1"/><rect x="19.34" y="16.77" width="3.36" height="10.56" rx="1"/><rect x="12" y="17" width="2.2" height="10" rx="1" transform="rotate(38 13.1 22)"/></symbol>
|
||||
<symbol id="qz-S" viewBox="0 0 20 32"><rect x="2.40" y="1.28" width="15.20" height="2.88" rx="1"/><rect x="16.12" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="15.04" width="15.20" height="2.88" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="27.52" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-T" viewBox="0 0 20 32"><rect x="2.40" y="1.28" width="15.20" height="2.88" rx="1"/><rect x="2.40" y="15.04" width="15.20" height="2.88" rx="1"/><rect x="2.40" y="27.52" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-U" viewBox="0 0 20 32"><rect x="1.08" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="1.08" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="27.52" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-V" viewBox="0 0 20 32"><rect x="1.08" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="1.08" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="7" y="17" width="2.2" height="12" rx="1" transform="rotate(24 8.1 23)"/><rect x="12" y="17" width="2.2" height="12" rx="1" transform="rotate(-24 13.1 23)"/></symbol>
|
||||
<symbol id="qz-W" viewBox="0 0 24 32"><rect x="1.30" y="4.16" width="3.36" height="10.56" rx="1"/><rect x="19.34" y="4.16" width="3.36" height="10.56" rx="1"/><rect x="1.30" y="16.77" width="3.36" height="10.56" rx="1"/><rect x="19.34" y="16.77" width="3.36" height="10.56" rx="1"/><rect x="2.88" y="27.52" width="18.24" height="2.88" rx="1"/><rect x="5" y="17" width="2" height="11" rx="1" transform="rotate(18 6 22.5)"/><rect x="9.5" y="17" width="2" height="11" rx="1" transform="rotate(-18 10.5 22.5)"/><rect x="14" y="17" width="2" height="11" rx="1" transform="rotate(18 15 22.5)"/></symbol>
|
||||
<symbol id="qz-X" viewBox="0 0 24 32"><rect x="1.30" y="4.16" width="3.36" height="10.56" rx="1"/><rect x="19.34" y="4.16" width="3.36" height="10.56" rx="1"/><rect x="1.30" y="16.77" width="3.36" height="10.56" rx="1"/><rect x="19.34" y="16.77" width="3.36" height="10.56" rx="1"/><rect x="7" y="5" width="2.2" height="11" rx="1" transform="rotate(32 8.1 10.5)"/><rect x="11" y="16" width="2.2" height="11" rx="1" transform="rotate(-32 12.1 21.5)"/></symbol>
|
||||
<symbol id="qz-Y" viewBox="0 0 20 32"><rect x="1.08" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="16.12" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="15.04" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-Z" viewBox="0 0 20 32"><rect x="2.40" y="1.28" width="15.20" height="2.88" rx="1"/><rect x="1.08" y="4.16" width="2.80" height="10.56" rx="1"/><rect x="1.08" y="16.77" width="2.80" height="10.56" rx="1"/><rect x="2.40" y="27.52" width="15.20" height="2.88" rx="1"/></symbol>
|
||||
<symbol id="qz-colon" viewBox="0 0 12 32"><rect x="4" y="10" width="4" height="4" rx="1"/><rect x="4" y="20" width="4" height="4" rx="1"/></symbol>
|
||||
<symbol id="qz-plus" viewBox="0 0 16 32"><rect x="6" y="8" width="4" height="16" rx="1"/><rect x="2" y="14" width="12" height="4" rx="1"/></symbol>
|
||||
<symbol id="qz-minus" viewBox="0 0 16 32"><rect x="2" y="14" width="12" height="4" rx="1"/></symbol>
|
||||
<symbol id="qz-slash" viewBox="0 0 14 32"><rect x="13" y="4" width="2.2" height="10" rx="1" transform="rotate(52 14.1 9)"/><rect x="5" y="18" width="2.2" height="10" rx="1" transform="rotate(52 6.1 23)"/></symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -188,11 +188,12 @@
|
||||
}
|
||||
|
||||
.hub-clock-widget {
|
||||
--cw-lcd-x: 24px;
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
top: 24px;
|
||||
left: auto;
|
||||
width: 420px;
|
||||
width: 567px;
|
||||
z-index: 3;
|
||||
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.48);
|
||||
@@ -204,7 +205,7 @@
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
padding: 60px;
|
||||
padding: 40px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -246,7 +247,9 @@
|
||||
.cw-primary-block {
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-rows: repeat(3, minmax(0, 1fr));
|
||||
grid-template-rows: auto auto auto;
|
||||
align-content: start;
|
||||
row-gap: 0;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 180, 80, 0.35);
|
||||
}
|
||||
@@ -263,7 +266,7 @@
|
||||
align-items: end;
|
||||
overflow: hidden;
|
||||
container-type: inline-size;
|
||||
padding: 16px 0 6px;
|
||||
padding: 12px 0 4px;
|
||||
}
|
||||
|
||||
.cw-line-time .cw-time-main {
|
||||
@@ -273,15 +276,19 @@
|
||||
.cw-row-tz,
|
||||
.cw-row-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cw-time-main {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto auto auto;
|
||||
align-items: end;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
column-gap: 6px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
width: fit-content;
|
||||
@@ -296,54 +303,57 @@
|
||||
.ot-lcd-line {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ot-lcd-bold .ot-glyph-wrap--bold {
|
||||
transform: scale(1.08, 1.12);
|
||||
transform-origin: bottom center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
#cw-hm-lcd .ot-glyph {
|
||||
height: 136px;
|
||||
height: calc(var(--cw-lcd-x) * 2);
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@container (max-width: 300px) {
|
||||
#cw-hm-lcd .ot-glyph {
|
||||
height: 108px;
|
||||
}
|
||||
|
||||
.ot-lcd-sec .ot-glyph {
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
#cw-colon-lcd .ot-glyph,
|
||||
.ot-lcd-sec .ot-glyph,
|
||||
.ot-lcd-tz .ot-glyph {
|
||||
height: 24px;
|
||||
height: var(--cw-lcd-x);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@container (max-width: 300px) {
|
||||
.hub-clock-widget {
|
||||
--cw-lcd-x: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.ot-lcd-sec {
|
||||
margin-left: 8px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.ot-lcd-sec .ot-glyph-wrap:first-child {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.ot-lcd-sec .ot-glyph {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.ot-lcd-tz .ot-glyph {
|
||||
height: 30px;
|
||||
width: auto;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.ot-lcd-tz {
|
||||
margin-left: 14px;
|
||||
margin-left: calc(var(--cw-lcd-x) * 2);
|
||||
}
|
||||
|
||||
.ot-lcd-subline {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cw-line-region.ot-lcd-subline .ot-glyph {
|
||||
height: calc(var(--cw-lcd-x) * 0.5);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.cw-line-date.ot-lcd-subline .ot-glyph {
|
||||
height: var(--cw-lcd-x);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.ot-glyph-wrap {
|
||||
@@ -362,6 +372,23 @@
|
||||
filter: drop-shadow(0 0 5px rgba(190, 240, 150, 0.38));
|
||||
}
|
||||
|
||||
.ot-fallback-char {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
min-width: 0.62em;
|
||||
color: #d8f6b8;
|
||||
font-family: "Consolas", "Menlo", "Monaco", monospace;
|
||||
font-size: 1em;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.02em;
|
||||
text-shadow: 0 0 5px rgba(190, 240, 150, 0.42);
|
||||
}
|
||||
|
||||
.ot-fallback-char--bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ot-glyph .ot-grid rect {
|
||||
fill: rgba(20, 30, 24, 0.85);
|
||||
filter: drop-shadow(0 0 1.4px rgba(135, 205, 255, 0.5));
|
||||
@@ -377,7 +404,7 @@
|
||||
|
||||
.cw-line-region {
|
||||
color: #b8c2d4;
|
||||
font-size: 10px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.16em;
|
||||
font-weight: 300;
|
||||
text-transform: uppercase;
|
||||
@@ -385,7 +412,7 @@
|
||||
|
||||
.cw-line-date {
|
||||
color: #c5d0e2;
|
||||
font-size: 10px;
|
||||
font-size: 24px;
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 400;
|
||||
text-transform: uppercase;
|
||||
@@ -752,7 +779,7 @@
|
||||
left: auto;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
width: min(94vw, 420px);
|
||||
width: min(94vw, 567px);
|
||||
min-width: 0;
|
||||
}
|
||||
.hub-cards {
|
||||
|
||||
@@ -11,33 +11,32 @@
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/app/androidcast_project/crashes/assets/css/app.css">
|
||||
<link rel="stylesheet" href="hub.css?v=20260527v">
|
||||
<link rel="stylesheet" href="hub.css?v=20260529a">
|
||||
<link rel="stylesheet" href="hub-logo-layers.css">
|
||||
<script src="assets/analytics.config.js"></script>
|
||||
<script src="/app/androidcast_project/crashes/assets/js/analytics.js" defer></script>
|
||||
</head>
|
||||
<body class="hub-page">
|
||||
<div id="overtime-alphabet-host" hidden aria-hidden="true"></div>
|
||||
<div id="quartz-alphabet-host" hidden aria-hidden="true"></div>
|
||||
<div id="hub-logo-stage" class="hub-logo-stage" hidden aria-hidden="true"></div>
|
||||
<section class="hub-clock-widget" id="hub-clock-widget" aria-label="World clock widget">
|
||||
<div class="cw-row cw-row-main">
|
||||
<div class="cw-cell cw-main-cell">
|
||||
<div class="cw-line cw-line-time">
|
||||
<div class="cw-clock-inner">
|
||||
<div class="cw-primary-block">
|
||||
<div class="cw-line cw-row cw-row-clock cw-line-time">
|
||||
<strong class="cw-time-main">
|
||||
<span id="cw-hm-lcd" class="ot-lcd-line ot-lcd-bold" aria-label="Hours and minutes"></span>
|
||||
<span id="cw-ss-lcd" class="ot-lcd-line ot-lcd-sec ot-lcd-bold" aria-label="Seconds"></span>
|
||||
<span id="cw-tz-text" class="ot-lcd-line ot-lcd-tz ot-lcd-bold" aria-label="Timezone offset"></span>
|
||||
<span id="cw-hm-lcd" class="ot-lcd-line ot-lcd-bold cw-col-hm" aria-label="Hours and minutes"></span>
|
||||
<span id="cw-colon-lcd" class="ot-lcd-line ot-lcd-colon cw-colon-blink" aria-label="Separator"></span>
|
||||
<span id="cw-ss-lcd" class="ot-lcd-line ot-lcd-sec cw-col-ss" aria-label="Seconds"></span>
|
||||
<span id="cw-tz-text" class="ot-lcd-line ot-lcd-tz cw-col-tz" aria-label="Timezone offset"></span>
|
||||
</strong>
|
||||
</div>
|
||||
<div class="cw-line cw-line-region" id="cw-region">EUROPE/WARSAW</div>
|
||||
<div class="cw-line cw-line-date" id="cw-date-line">WED 27 MAY 2026</div>
|
||||
<div class="cw-line cw-row cw-row-tz cw-line-region ot-lcd-subline" id="cw-region">EUROPE/WARSAW</div>
|
||||
<div class="cw-line cw-row cw-row-date cw-line-date ot-lcd-subline" id="cw-date-line">WED 27 MAY 2026</div>
|
||||
</div>
|
||||
<div class="cw-separator" aria-hidden="true"></div>
|
||||
<div class="cw-world-section" id="cw-world-row"></div>
|
||||
</div>
|
||||
<div class="cw-row cw-row-mid">
|
||||
<div class="cw-cell"></div>
|
||||
<div class="cw-cell"></div>
|
||||
<div class="cw-cell"></div>
|
||||
<div class="cw-cell"></div>
|
||||
</div>
|
||||
<div class="cw-row cw-row-world" id="cw-world-row"></div>
|
||||
</section>
|
||||
<div id="hub-docs-modal" class="hub-docs-modal" hidden aria-hidden="true">
|
||||
<div class="hub-docs-backdrop" data-close-docs></div>
|
||||
@@ -59,7 +58,10 @@
|
||||
<header class="hub-header">
|
||||
<div class="hub-brand">
|
||||
<img class="hub-logo-mark" src="assets/hub-logo-transparent.svg" alt="" width="112" height="84" decoding="async">
|
||||
<div class="hub-title-with-thunder">
|
||||
<h1 class="hub-title">Android Cast</h1>
|
||||
<img class="hub-thunder" src="assets/hub-thunder.svg?v=20260527z" alt="" decoding="async">
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted hub-subtitle">Project hub</p>
|
||||
<label class="toolbar-select hub-theme">
|
||||
@@ -114,7 +116,7 @@
|
||||
var supportsLayout = !!(window.CSS && CSS.supports && CSS.supports('object-fit', 'cover'));
|
||||
if (!supportsSvg || !supportsLayout) return;
|
||||
|
||||
var logoBuild = '20260527s';
|
||||
var logoBuild = '20260529a';
|
||||
var fragments = {
|
||||
space: 'assets/hub-logo-fragment-space.svg',
|
||||
globe: 'assets/hub-logo-fragment-globe.svg',
|
||||
@@ -464,12 +466,15 @@
|
||||
|
||||
var overtimeReady = false;
|
||||
var overtimeSymbolIds = {};
|
||||
var quartzReady = false;
|
||||
var quartzSymbolIds = {};
|
||||
|
||||
function overtimeSymbolId(ch) {
|
||||
if (ch >= '0' && ch <= '9') return 'ot-' + ch;
|
||||
if (ch === ':') return 'ot-colon';
|
||||
if (ch === '+') return 'ot-plus';
|
||||
if (ch === '-') return 'ot-minus';
|
||||
if (ch === '/') return 'ot-slash';
|
||||
if (ch === ' ') return 'ot-space';
|
||||
if (ch >= 'A' && ch <= 'Z') return 'ot-' + ch;
|
||||
return null;
|
||||
@@ -491,60 +496,96 @@
|
||||
.catch(function () { done(false); });
|
||||
}
|
||||
|
||||
function buildGlyphGrid(svg, vb) {
|
||||
function quartzSymbolId(ch) {
|
||||
if (ch >= '0' && ch <= '9') return 'qz-' + ch;
|
||||
if (ch === ':') return 'qz-colon';
|
||||
if (ch === '+') return 'qz-plus';
|
||||
if (ch === '-') return 'qz-minus';
|
||||
if (ch === '/') return 'qz-slash';
|
||||
if (ch === ' ') return 'qz-space';
|
||||
if (ch >= 'A' && ch <= 'Z') return 'qz-' + ch;
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadQuartzAlphabet(done) {
|
||||
var host = document.getElementById('quartz-alphabet-host');
|
||||
if (!host) { done(false); return; }
|
||||
fetch('assets/quartz-alphabet.svg?v=' + logoBuild)
|
||||
.then(function (res) { return res.text(); })
|
||||
.then(function (svgText) {
|
||||
host.innerHTML = svgText;
|
||||
host.querySelectorAll('symbol').forEach(function (sym) {
|
||||
if (sym.id) quartzSymbolIds[sym.id] = sym;
|
||||
});
|
||||
quartzReady = true;
|
||||
done(true);
|
||||
})
|
||||
.catch(function () { done(false); });
|
||||
}
|
||||
|
||||
var OVERTIME_SEG_GRID = [
|
||||
[3, 1, 14, 3], [1, 4, 3, 11], [16, 4, 3, 11],
|
||||
[3, 15, 14, 3], [1, 17, 3, 11], [16, 17, 3, 11],
|
||||
[3, 28, 14, 3]
|
||||
];
|
||||
|
||||
function parseOvertimeRects(sym) {
|
||||
var rects = [];
|
||||
sym.querySelectorAll('rect').forEach(function (node) {
|
||||
rects.push({
|
||||
x: parseFloat(node.getAttribute('x') || 0),
|
||||
y: parseFloat(node.getAttribute('y') || 0),
|
||||
w: parseFloat(node.getAttribute('width') || 0),
|
||||
h: parseFloat(node.getAttribute('height') || 0)
|
||||
});
|
||||
});
|
||||
return rects;
|
||||
}
|
||||
|
||||
function overtimeSegMatchesRect(seg, rect) {
|
||||
var eps = 1.25;
|
||||
return Math.abs(rect.x - seg[0]) <= eps
|
||||
&& Math.abs(rect.y - seg[1]) <= eps
|
||||
&& Math.abs(rect.w - seg[2]) <= eps
|
||||
&& Math.abs(rect.h - seg[3]) <= eps;
|
||||
}
|
||||
|
||||
function overtimeSegIsLit(seg, activeRects) {
|
||||
for (var i = 0; i < activeRects.length; i++) {
|
||||
if (overtimeSegMatchesRect(seg, activeRects[i])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildOvertimeInactiveGrid(svg, sym, glyphW) {
|
||||
if (glyphW === 12 || glyphW === 8 || glyphW === 16) return;
|
||||
if (glyphW !== 20 && glyphW < 22) return;
|
||||
var activeRects = parseOvertimeRects(sym);
|
||||
var seg = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
seg.setAttribute('class', 'ot-grid');
|
||||
var w = vb.width || 20;
|
||||
var h = vb.height || 32;
|
||||
var hPad = Math.max(1, w * 0.12);
|
||||
var topY = Math.max(1, h * 0.04);
|
||||
var midY = h * 0.47;
|
||||
var botY = h * 0.86;
|
||||
var segW = Math.max(6, w - hPad * 2);
|
||||
var segH = Math.max(2, h * 0.09);
|
||||
var vW = Math.max(2, w * 0.14);
|
||||
var vH = Math.max(6, h * 0.33);
|
||||
var leftX = Math.max(1, hPad * 0.45);
|
||||
var rightX = Math.max(leftX + vW + 1, w - leftX - vW);
|
||||
var upperY = topY + segH;
|
||||
var lowerY = midY + segH * 0.6;
|
||||
var defs = [
|
||||
[hPad, topY, segW, segH],
|
||||
[leftX, upperY, vW, vH],
|
||||
[rightX, upperY, vW, vH],
|
||||
[hPad, midY, segW, segH],
|
||||
[leftX, lowerY, vW, vH],
|
||||
[rightX, lowerY, vW, vH],
|
||||
[hPad, botY, segW, segH]
|
||||
];
|
||||
defs.forEach(function (d) {
|
||||
OVERTIME_SEG_GRID.forEach(function (d) {
|
||||
if (overtimeSegIsLit(d, activeRects)) return;
|
||||
var r = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
r.setAttribute('x', d[0].toFixed(2));
|
||||
r.setAttribute('y', d[1].toFixed(2));
|
||||
r.setAttribute('width', d[2].toFixed(2));
|
||||
r.setAttribute('height', d[3].toFixed(2));
|
||||
r.setAttribute('x', String(d[0]));
|
||||
r.setAttribute('y', String(d[1]));
|
||||
r.setAttribute('width', String(d[2]));
|
||||
r.setAttribute('height', String(d[3]));
|
||||
r.setAttribute('rx', '1');
|
||||
seg.appendChild(r);
|
||||
});
|
||||
if (w >= 22) {
|
||||
var d1 = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
d1.setAttribute('x', (w * 0.34).toFixed(2));
|
||||
d1.setAttribute('y', (h * 0.15).toFixed(2));
|
||||
d1.setAttribute('width', Math.max(2, w * 0.10).toFixed(2));
|
||||
d1.setAttribute('height', Math.max(6, h * 0.34).toFixed(2));
|
||||
d1.setAttribute('rx', '1');
|
||||
d1.setAttribute('transform', 'rotate(22 ' + (w * 0.39).toFixed(2) + ' ' + (h * 0.32).toFixed(2) + ')');
|
||||
seg.appendChild(d1);
|
||||
var d2 = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
d2.setAttribute('x', (w * 0.56).toFixed(2));
|
||||
d2.setAttribute('y', (h * 0.15).toFixed(2));
|
||||
d2.setAttribute('width', Math.max(2, w * 0.10).toFixed(2));
|
||||
d2.setAttribute('height', Math.max(6, h * 0.34).toFixed(2));
|
||||
d2.setAttribute('rx', '1');
|
||||
d2.setAttribute('transform', 'rotate(-22 ' + (w * 0.61).toFixed(2) + ' ' + (h * 0.32).toFixed(2) + ')');
|
||||
seg.appendChild(d2);
|
||||
if (seg.childNodes.length) svg.appendChild(seg);
|
||||
}
|
||||
svg.appendChild(seg);
|
||||
|
||||
function cloneOvertimeSymbol(svg, sym) {
|
||||
sym.querySelectorAll('rect').forEach(function (node) {
|
||||
var rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
['x', 'y', 'width', 'height', 'rx', 'transform'].forEach(function (attr) {
|
||||
var val = node.getAttribute(attr);
|
||||
if (val != null) rect.setAttribute(attr, val);
|
||||
});
|
||||
rect.setAttribute('class', 'ot-active');
|
||||
svg.appendChild(rect);
|
||||
});
|
||||
}
|
||||
|
||||
function renderOvertimeText(el, text, opts) {
|
||||
@@ -556,8 +597,21 @@
|
||||
return;
|
||||
}
|
||||
String(text).toUpperCase().split('').forEach(function (ch) {
|
||||
if (opts.forceFallback) {
|
||||
var forced = document.createElement('span');
|
||||
forced.className = 'ot-fallback-char' + (opts.bold ? ' ot-fallback-char--bold' : '');
|
||||
forced.textContent = ch === ' ' ? '\u00A0' : ch;
|
||||
el.appendChild(forced);
|
||||
return;
|
||||
}
|
||||
var sid = overtimeSymbolId(ch);
|
||||
if (!sid || !overtimeSymbolIds[sid]) return;
|
||||
if (!sid || !overtimeSymbolIds[sid]) {
|
||||
var fb = document.createElement('span');
|
||||
fb.className = 'ot-fallback-char' + (opts.bold ? ' ot-fallback-char--bold' : '');
|
||||
fb.textContent = ch === ' ' ? '\u00A0' : ch;
|
||||
el.appendChild(fb);
|
||||
return;
|
||||
}
|
||||
var wrap = document.createElement('span');
|
||||
wrap.className = 'ot-glyph-wrap' + (opts.bold ? ' ot-glyph-wrap--bold' : '');
|
||||
if (opts.blinkColon && ch === ':') wrap.className += ' ot-colon-blink';
|
||||
@@ -566,19 +620,82 @@
|
||||
var viewBox = sym.getAttribute('viewBox') || '0 0 20 32';
|
||||
svg.setAttribute('viewBox', viewBox);
|
||||
svg.setAttribute('class', 'ot-glyph');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
var vb = viewBox.split(/\s+/).map(function (v) { return Number(v) || 0; });
|
||||
buildGlyphGrid(svg, { width: vb[2] || 20, height: vb[3] || 32 });
|
||||
var glyphW = vb[2] || 20;
|
||||
if (opts.ghost !== false) buildOvertimeInactiveGrid(svg, sym, glyphW);
|
||||
cloneOvertimeSymbol(svg, sym);
|
||||
wrap.appendChild(svg);
|
||||
el.appendChild(wrap);
|
||||
});
|
||||
}
|
||||
|
||||
/* Clock digits: original Overtime path (<use> only, no ghost grid). */
|
||||
function renderOvertimeDigits(el, text, blinkColon, bold) {
|
||||
if (!el) return;
|
||||
el.innerHTML = '';
|
||||
if (!overtimeReady) {
|
||||
el.textContent = text;
|
||||
return;
|
||||
}
|
||||
text.split('').forEach(function (ch) {
|
||||
var sid = overtimeSymbolId(ch);
|
||||
if (!sid || !overtimeSymbolIds[sid]) {
|
||||
el.appendChild(document.createTextNode(ch));
|
||||
return;
|
||||
}
|
||||
var wrap = document.createElement('span');
|
||||
wrap.className = 'ot-glyph-wrap' + (bold ? ' ot-glyph-wrap--bold' : '');
|
||||
if (blinkColon && ch === ':') wrap.className += ' ot-colon-blink';
|
||||
var sym = overtimeSymbolIds[sid];
|
||||
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', sym.getAttribute('viewBox') || '0 0 20 32');
|
||||
svg.setAttribute('class', 'ot-glyph');
|
||||
var use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
|
||||
use.setAttribute('href', '#' + sid);
|
||||
use.setAttribute('class', 'ot-active');
|
||||
svg.appendChild(use);
|
||||
wrap.appendChild(svg);
|
||||
el.appendChild(wrap);
|
||||
});
|
||||
}
|
||||
|
||||
function renderOvertimeDigits(el, text, blinkColon, bold) {
|
||||
renderOvertimeText(el, text, { blinkColon: blinkColon, bold: bold });
|
||||
function renderQuartzText(el, text, opts) {
|
||||
opts = opts || {};
|
||||
if (!el) return;
|
||||
el.innerHTML = '';
|
||||
if (!quartzReady) {
|
||||
el.textContent = text;
|
||||
return;
|
||||
}
|
||||
String(text).toUpperCase().split('').forEach(function (ch) {
|
||||
var sid = quartzSymbolId(ch);
|
||||
if (!sid || !quartzSymbolIds[sid]) {
|
||||
var fb = document.createElement('span');
|
||||
fb.className = 'qz-fallback-char' + (opts.bold ? ' qz-fallback-char--bold' : '');
|
||||
fb.textContent = ch === ' ' ? '\u00A0' : ch;
|
||||
el.appendChild(fb);
|
||||
return;
|
||||
}
|
||||
var wrap = document.createElement('span');
|
||||
wrap.className = 'qz-glyph-wrap' + (opts.bold ? ' qz-glyph-wrap--bold' : '');
|
||||
if (opts.blinkColon && ch === ':') wrap.className += ' qz-colon-blink';
|
||||
var sym = quartzSymbolIds[sid];
|
||||
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
var viewBox = sym.getAttribute('viewBox') || '0 0 20 32';
|
||||
svg.setAttribute('viewBox', viewBox);
|
||||
svg.setAttribute('class', 'qz-glyph');
|
||||
var vb = viewBox.split(/\s+/).map(function (v) { return Number(v) || 0; });
|
||||
var use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
|
||||
use.setAttribute('href', '#' + sid);
|
||||
use.setAttribute('class', 'qz-active');
|
||||
svg.appendChild(use);
|
||||
wrap.appendChild(svg);
|
||||
el.appendChild(wrap);
|
||||
});
|
||||
}
|
||||
|
||||
function renderQuartzDigits(el, text, blinkColon, bold) {
|
||||
renderQuartzText(el, text, { blinkColon: blinkColon, bold: bold });
|
||||
}
|
||||
|
||||
function regionLabel(tz) {
|
||||
@@ -623,11 +740,12 @@
|
||||
function updateClockWidget() {
|
||||
var now = new Date();
|
||||
var p = timeParts(now, primaryTz);
|
||||
renderOvertimeDigits(document.getElementById('cw-hm-lcd'), p.hh + ':' + p.mm, false, true);
|
||||
renderOvertimeDigits(document.getElementById('cw-ss-lcd'), ':' + p.ss, true, true);
|
||||
renderOvertimeText(document.getElementById('cw-tz-text'), gmtOffsetLabel(now, primaryTz), { bold: true });
|
||||
document.getElementById('cw-region').textContent = regionLabel(primaryTz);
|
||||
document.getElementById('cw-date-line').textContent = formatDateLine(now, primaryTz);
|
||||
renderOvertimeDigits(document.getElementById('cw-hm-lcd'), p.hh + p.mm, false, false);
|
||||
renderOvertimeDigits(document.getElementById('cw-colon-lcd'), ':', false, false);
|
||||
renderOvertimeDigits(document.getElementById('cw-ss-lcd'), p.ss, false, false);
|
||||
renderOvertimeText(document.getElementById('cw-tz-text'), gmtOffsetLabel(now, primaryTz), { bold: false });
|
||||
renderOvertimeText(document.getElementById('cw-region'), regionLabel(primaryTz), { bold: false });
|
||||
renderOvertimeText(document.getElementById('cw-date-line'), formatDateLine(now, primaryTz), { bold: false });
|
||||
|
||||
secondary.forEach(function (clock) {
|
||||
var t = timeParts(now, clock.tz);
|
||||
@@ -651,12 +769,15 @@
|
||||
h.className = 'cw-resize-handle cw-resize-' + pos;
|
||||
h.dataset.resize = pos;
|
||||
h.setAttribute('aria-hidden', 'true');
|
||||
var visual = document.createElement('span');
|
||||
visual.className = 'cw-resize-handle-visual';
|
||||
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 45.279 45.279');
|
||||
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', arrowPath);
|
||||
svg.appendChild(path);
|
||||
h.appendChild(svg);
|
||||
visual.appendChild(svg);
|
||||
h.appendChild(visual);
|
||||
widget.appendChild(h);
|
||||
});
|
||||
|
||||
@@ -687,7 +808,7 @@
|
||||
widget.style.left = Math.max(0, ev.clientX - drag.dx) + 'px';
|
||||
widget.style.top = Math.max(0, ev.clientY - drag.dy) + 'px';
|
||||
} else {
|
||||
var minW = 1, minH = 1;
|
||||
var minW = 280, minH = 210;
|
||||
var dx = ev.clientX - drag.x;
|
||||
var dy = ev.clientY - drag.y;
|
||||
var left = drag.left, top = drag.top, width = drag.width, height = drag.height;
|
||||
@@ -704,7 +825,7 @@
|
||||
widget.style.left = Math.max(0, left) + 'px';
|
||||
widget.style.top = Math.max(0, top) + 'px';
|
||||
widget.style.width = width + 'px';
|
||||
widget.style.minWidth = '';
|
||||
widget.style.minWidth = width + 'px';
|
||||
widget.style.transform = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -41,6 +41,23 @@ php -S 127.0.0.1:8080 -t public
|
||||
|
||||
Open http://127.0.0.1:8080/ — login **admin** / **admin**.
|
||||
|
||||
## Google Analytics 4 (optional)
|
||||
|
||||
In `config/config.php`:
|
||||
|
||||
```php
|
||||
'analytics' => [
|
||||
'enabled' => true,
|
||||
'measurement_id' => 'G-XXXXXXXXXX',
|
||||
'debug' => false,
|
||||
],
|
||||
```
|
||||
|
||||
Loads `public/assets/js/analytics.js` from `layout.php` and `login.php`. The project hub at
|
||||
`/app/androidcast_project/` uses the same script; configure `examples/app_hub/assets/analytics.config.js`.
|
||||
|
||||
Events include `page_view`, `console_view` (home, tickets, reports, …), and hub `hub_nav_click` / `hub_open_docs`.
|
||||
|
||||
## RBAC (phase 1)
|
||||
|
||||
Multi-tenant foundation (no admin UI yet):
|
||||
|
||||
@@ -37,4 +37,10 @@ return [
|
||||
'default_company_slug' => 'default',
|
||||
'default_company_id' => 1,
|
||||
],
|
||||
// Google Analytics 4 (hub + crash console share public/assets/js/analytics.js)
|
||||
'analytics' => [
|
||||
'enabled' => false,
|
||||
'measurement_id' => '', // e.g. G-XXXXXXXXXX
|
||||
'debug' => false,
|
||||
],
|
||||
];
|
||||
|
||||
145
examples/crash_reporter/backend/public/assets/js/analytics.js
Normal file
145
examples/crash_reporter/backend/public/assets/js/analytics.js
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Google Analytics 4 for Android Cast web platform (hub + crash console).
|
||||
* Set window.ANDROIDCAST_ANALYTICS before this script loads:
|
||||
* { measurementId: 'G-XXXXXXXX', platform: 'hub'|'crashes', debug: false }
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var cfg = global.ANDROIDCAST_ANALYTICS || {};
|
||||
var measurementId = (cfg.measurementId || '').trim();
|
||||
var platform = cfg.platform || 'web';
|
||||
var debug = !!cfg.debug;
|
||||
|
||||
function log() {
|
||||
if (debug && global.console) {
|
||||
global.console.log.apply(global.console, ['[analytics]'].concat([].slice.call(arguments)));
|
||||
}
|
||||
}
|
||||
|
||||
function enabled() {
|
||||
return measurementId.length > 0 && /^G-[A-Z0-9]+$/i.test(measurementId);
|
||||
}
|
||||
|
||||
function ensureGtag(cb) {
|
||||
if (!enabled()) {
|
||||
return;
|
||||
}
|
||||
global.dataLayer = global.dataLayer || [];
|
||||
if (!global.gtag) {
|
||||
global.gtag = function () {
|
||||
global.dataLayer.push(arguments);
|
||||
};
|
||||
}
|
||||
if (global.__acAnalyticsLoaded) {
|
||||
if (cb) cb();
|
||||
return;
|
||||
}
|
||||
var s = document.createElement('script');
|
||||
s.async = true;
|
||||
s.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(measurementId);
|
||||
s.onload = function () {
|
||||
global.__acAnalyticsLoaded = true;
|
||||
global.gtag('js', new Date());
|
||||
global.gtag('config', measurementId, {
|
||||
send_page_view: false,
|
||||
anonymize_ip: true
|
||||
});
|
||||
log('loaded', measurementId);
|
||||
if (cb) cb();
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function pagePath() {
|
||||
var base = (cfg.basePath || '').replace(/\/$/, '');
|
||||
var path = global.location.pathname || '/';
|
||||
if (base && path.indexOf(base) === 0) {
|
||||
path = path.slice(base.length) || '/';
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function pageView(extra) {
|
||||
if (!enabled()) return;
|
||||
ensureGtag(function () {
|
||||
var params = {
|
||||
page_title: document.title || '',
|
||||
page_location: global.location.href,
|
||||
page_path: pagePath(),
|
||||
platform: platform
|
||||
};
|
||||
if (extra) {
|
||||
for (var k in extra) {
|
||||
if (Object.prototype.hasOwnProperty.call(extra, k)) params[k] = extra[k];
|
||||
}
|
||||
}
|
||||
global.gtag('event', 'page_view', params);
|
||||
log('page_view', params);
|
||||
});
|
||||
}
|
||||
|
||||
function trackEvent(name, params) {
|
||||
if (!enabled() || !name) return;
|
||||
ensureGtag(function () {
|
||||
var payload = params || {};
|
||||
payload.platform = platform;
|
||||
global.gtag('event', name, payload);
|
||||
log('event', name, payload);
|
||||
});
|
||||
}
|
||||
|
||||
function bindConsoleView() {
|
||||
var body = document.body;
|
||||
if (!body) return;
|
||||
var view = body.getAttribute('data-view');
|
||||
if (view) {
|
||||
trackEvent('console_view', { view_name: view });
|
||||
}
|
||||
}
|
||||
|
||||
function bindHubInteractions() {
|
||||
document.addEventListener('click', function (ev) {
|
||||
var t = ev.target;
|
||||
if (!(t instanceof Element)) return;
|
||||
var card = t.closest('.hub-card, .hub-card-link');
|
||||
if (card) {
|
||||
var label = card.getAttribute('data-analytics-label')
|
||||
|| (card.textContent || '').trim().slice(0, 64);
|
||||
trackEvent('hub_nav_click', { link_text: label });
|
||||
return;
|
||||
}
|
||||
if (t.closest('[data-open-docs], #hub-compass-layer, .hub-logo-layer--compass')) {
|
||||
trackEvent('hub_open_docs', { source: 'compass' });
|
||||
}
|
||||
if (t.closest('[data-close-docs]')) {
|
||||
trackEvent('hub_close_docs', {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (!enabled()) {
|
||||
log('disabled (no measurement id)');
|
||||
return;
|
||||
}
|
||||
pageView();
|
||||
if (platform === 'crashes') {
|
||||
bindConsoleView();
|
||||
} else if (platform === 'hub') {
|
||||
bindHubInteractions();
|
||||
}
|
||||
}
|
||||
|
||||
global.AndroidCastAnalytics = {
|
||||
enabled: enabled,
|
||||
pageView: pageView,
|
||||
trackEvent: trackEvent
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
26
examples/crash_reporter/backend/src/AnalyticsHead.php
Normal file
26
examples/crash_reporter/backend/src/AnalyticsHead.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Emits GA4 config + shared analytics.js for PHP views. */
|
||||
final class AnalyticsHead {
|
||||
public static function render(string $platform = 'crashes'): void {
|
||||
$enabled = (bool) cfg('analytics.enabled', false);
|
||||
$measurementId = trim((string) cfg('analytics.measurement_id', ''));
|
||||
if (!$enabled || $measurementId === '') {
|
||||
return;
|
||||
}
|
||||
$basePath = Auth::basePath();
|
||||
$config = [
|
||||
'measurementId' => $measurementId,
|
||||
'platform' => $platform,
|
||||
'basePath' => $basePath,
|
||||
'debug' => (bool) cfg('analytics.debug', false),
|
||||
];
|
||||
$json = json_encode($config, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP);
|
||||
if ($json === false) {
|
||||
return;
|
||||
}
|
||||
echo '<script>window.ANDROIDCAST_ANALYTICS=', $json, ';</script>', "\n";
|
||||
echo '<script src="', h($basePath), '/assets/js/analytics.js" defer></script>', "\n";
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ require_once __DIR__ . '/TicketAttachmentRepository.php';
|
||||
require_once __DIR__ . '/TicketCommentRepository.php';
|
||||
require_once __DIR__ . '/UserRepository.php';
|
||||
require_once __DIR__ . '/TicketRepository.php';
|
||||
require_once __DIR__ . '/AnalyticsHead.php';
|
||||
|
||||
function cfg(string $key, $default = null) {
|
||||
global $config;
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<?php if (in_array($view ?? '', ['tickets', 'ticket'], true)): ?>
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/tickets.js" defer></script>
|
||||
<?php endif; ?>
|
||||
<?php AnalyticsHead::render('crashes'); ?>
|
||||
</head>
|
||||
<body data-base-path="<?= h(Auth::basePath()) ?>"
|
||||
data-view="<?= h($view ?? 'home') ?>"
|
||||
|
||||
@@ -26,6 +26,7 @@ $bp = Auth::basePath();
|
||||
</script>
|
||||
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
|
||||
<script src="<?= h($bp) ?>/assets/js/i18n.js" defer></script>
|
||||
<?php AnalyticsHead::render('crashes_login'); ?>
|
||||
</head>
|
||||
<body class="login-page" data-base-path="<?= h($bp) ?>">
|
||||
<div class="login-locale">
|
||||
|
||||
Reference in New Issue
Block a user