Initial commit
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
@page "/can-monitor"
|
||||
@rendermode InteractiveServer
|
||||
@implements IDisposable
|
||||
|
||||
@using Peak.Can.Basic
|
||||
|
||||
@inject ICanService CanService
|
||||
|
||||
<PageTitle>CAN Monitor</PageTitle>
|
||||
|
||||
<h1>CAN Monitor</h1>
|
||||
|
||||
@* ── Status bar ─────────────────────────────────────────────────────────────── *@
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
@if (CanService.IsConnected)
|
||||
{
|
||||
<span class="badge bg-success fs-6">● Connected</span>
|
||||
<span class="text-muted">Channel: <strong>@CanService.ChannelName</strong></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger fs-6">○ Not connected</span>
|
||||
<span class="text-muted small">Check that a PCAN USB adapter is plugged in and CanOptions are set in appsettings.json.</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* ── Live messages ──────────────────────────────────────────────────────────── *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center py-0 ps-0">
|
||||
<ul class="nav nav-tabs border-0 mb-0">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link rounded-0 @(msgTab == "stream" ? "active" : "")"
|
||||
@onclick='() => msgTab = "stream"'>
|
||||
Stream
|
||||
<span class="badge bg-secondary ms-1">@messages.Count</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link rounded-0 @(msgTab == "latest" ? "active" : "")"
|
||||
@onclick='() => msgTab = "latest"'>
|
||||
Latest
|
||||
<span class="badge bg-secondary ms-1">@latestMessages.Count</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<button class="btn btn-sm btn-outline-secondary me-2" @onclick="ClearMessages">Clear</button>
|
||||
</div>
|
||||
|
||||
@if (msgTab == "stream")
|
||||
{
|
||||
<div style="max-height:320px; overflow-y:auto;">
|
||||
<table class="table table-sm table-hover font-monospace mb-0">
|
||||
<thead class="table-light sticky-top">
|
||||
<tr>
|
||||
<th>Timestamp (µs)</th>
|
||||
<th>ID</th>
|
||||
<th>DLC</th>
|
||||
<th>Data</th>
|
||||
<th>Signals</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@lock (messages)
|
||||
{
|
||||
foreach (var m in messages)
|
||||
{
|
||||
<tr>
|
||||
<td>@m.TimestampUs</td>
|
||||
<td>@($"0x{m.Id:X3}")</td>
|
||||
<td>@m.Dlc</td>
|
||||
<td>@(m.Data.Length > 0 ? BitConverter.ToString(m.Data).Replace("-", " ") : "–")</td>
|
||||
<td>@SignalsText(m)</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="max-height:320px; overflow-y:auto;">
|
||||
<table class="table table-sm table-hover font-monospace mb-0">
|
||||
<thead class="table-light sticky-top">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>DLC</th>
|
||||
<th>Data</th>
|
||||
<th>Signals</th>
|
||||
<th>Timestamp (µs)</th>
|
||||
<th class="text-end">Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var m in GetLatestRows())
|
||||
{
|
||||
<tr>
|
||||
<td>@($"0x{m.Id:X3}")</td>
|
||||
<td>@m.Dlc</td>
|
||||
<td>@(m.Data.Length > 0 ? BitConverter.ToString(m.Data).Replace("-", " ") : "–")</td>
|
||||
<td>@SignalsText(m)</td>
|
||||
<td>@m.TimestampUs</td>
|
||||
<td class="text-end">@updateCounts.GetValueOrDefault(m.Id)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* ── Filters + Send (two columns) ──────────────────────────────────────────── *@
|
||||
<div class="row g-3 mb-3">
|
||||
|
||||
@* Filters *@
|
||||
<div class="col-lg-7">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
Filters
|
||||
<span class="text-muted small ms-2">
|
||||
@(filters.Count == 0 ? "(no filters — all messages pass)" : $"({filters.Count} active)")
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (filters.Count > 0)
|
||||
{
|
||||
<table class="table table-sm mb-2">
|
||||
<thead><tr><th>ID</th><th>Mask</th><th>Description</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var f in filters)
|
||||
{
|
||||
<tr>
|
||||
<td class="font-monospace">@($"0x{f.MessageId:X3}")</td>
|
||||
<td class="font-monospace">@($"0x{f.Mask:X3}")</td>
|
||||
<td>@f.Description</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-danger py-0 px-2"
|
||||
@onclick="() => RemoveFilter(f.Id)">×</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<div class="row g-1 align-items-end">
|
||||
<div class="col-3">
|
||||
<label class="form-label small mb-0">ID (hex)</label>
|
||||
<input class="form-control form-control-sm font-monospace"
|
||||
placeholder="e.g. 100" @bind="newFilterId" />
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<label class="form-label small mb-0">Mask (hex)</label>
|
||||
<input class="form-control form-control-sm font-monospace"
|
||||
placeholder="e.g. 7FF" @bind="newFilterMask" />
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label small mb-0">Description</label>
|
||||
<input class="form-control form-control-sm"
|
||||
placeholder="optional" @bind="newFilterDesc" />
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<button class="btn btn-sm btn-primary w-100" @onclick="AddFilter">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (filterError != null)
|
||||
{
|
||||
<div class="text-danger small mt-1">@filterError</div>
|
||||
}
|
||||
@if (filters.Count > 0)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger mt-2" @onclick="ClearFilters">Clear all</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Send *@
|
||||
<div class="col-lg-5">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">Send Message</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-0">CAN ID (hex)</label>
|
||||
<input class="form-control form-control-sm font-monospace"
|
||||
placeholder="e.g. 7FF" @bind="sendIdHex" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-0">Data bytes (hex, space-separated)</label>
|
||||
<input class="form-control form-control-sm font-monospace"
|
||||
placeholder="e.g. DE AD BE EF" @bind="sendDataHex" />
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="extFrame" @bind="sendExtended" />
|
||||
<label class="form-check-label small" for="extFrame">Extended frame (29-bit ID)</label>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button class="btn btn-sm btn-success" @onclick="SendMessage">Send</button>
|
||||
@if (sendFeedback != null)
|
||||
{
|
||||
<span class="small @(sendOk ? "text-success" : "text-danger")">@sendFeedback</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Bitmasks ───────────────────────────────────────────────────────────────── *@
|
||||
<div class="card">
|
||||
<div class="card-header">Signal Bitmasks</div>
|
||||
<div class="card-body">
|
||||
@if (bitmasks.Count == 0)
|
||||
{
|
||||
<p class="text-muted small mb-2">
|
||||
No bitmasks configured. Add one to extract named signal values from a message's data bytes.<br />
|
||||
<em>Formula: physicalValue = ((rawUint64 & DataMask) >> RightShift) × Scale + Offset</em>
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm mb-2">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Msg ID</th><th>Signal</th><th>DataMask (64-bit)</th>
|
||||
<th>Shift</th><th>Scale</th><th>Offset</th><th>Unit</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var b in bitmasks)
|
||||
{
|
||||
<tr>
|
||||
<td class="font-monospace">@($"0x{b.MessageId:X3}")</td>
|
||||
<td>@b.SignalName</td>
|
||||
<td class="font-monospace small">@($"0x{b.DataMask:X16}")</td>
|
||||
<td>@b.RightShift</td>
|
||||
<td>@b.Scale</td>
|
||||
<td>@b.Offset</td>
|
||||
<td>@b.Unit</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-danger py-0 px-2"
|
||||
@onclick="() => RemoveBitmask(b.Id)">×</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<div class="row g-1 align-items-end">
|
||||
<div class="col-sm-2">
|
||||
<label class="form-label small mb-0">Msg ID (hex)</label>
|
||||
<input class="form-control form-control-sm font-monospace" placeholder="e.g. 100" @bind="newBmMsgId" />
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<label class="form-label small mb-0">Signal name</label>
|
||||
<input class="form-control form-control-sm" placeholder="e.g. EngineRPM" @bind="newBmName" />
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label small mb-0">DataMask (64-bit hex)</label>
|
||||
<input class="form-control form-control-sm font-monospace" placeholder="e.g. 00FF000000000000" @bind="newBmMask" />
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<label class="form-label small mb-0">Shift</label>
|
||||
<input class="form-control form-control-sm" @bind="newBmShift" />
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<label class="form-label small mb-0">Scale</label>
|
||||
<input class="form-control form-control-sm" @bind="newBmScale" />
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<label class="form-label small mb-0">Offset</label>
|
||||
<input class="form-control form-control-sm" @bind="newBmOffset" />
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<label class="form-label small mb-0">Unit</label>
|
||||
<input class="form-control form-control-sm" placeholder="rpm" @bind="newBmUnit" />
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<button class="btn btn-sm btn-primary w-100" @onclick="AddBitmask">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (bitmaskError != null)
|
||||
{
|
||||
<div class="text-danger small mt-1">@bitmaskError</div>
|
||||
}
|
||||
@if (bitmasks.Count > 0)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger mt-2" @onclick="ClearBitmasks">Clear all</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
// ── Data ──────────────────────────────────────────────────────────────────
|
||||
private readonly List<CanMessageDto> messages = [];
|
||||
private readonly Dictionary<uint, CanMessageDto> latestMessages = new();
|
||||
private readonly Dictionary<uint, int> updateCounts = new();
|
||||
private string msgTab = "stream";
|
||||
private const int MaxMessages = 200;
|
||||
private List<CanFilter> filters = [];
|
||||
private List<CanBitmask> bitmasks = [];
|
||||
|
||||
// ── Filter form ───────────────────────────────────────────────────────────
|
||||
private string newFilterId = "";
|
||||
private string newFilterMask = "7FF";
|
||||
private string newFilterDesc = "";
|
||||
private string? filterError;
|
||||
|
||||
// ── Bitmask form ──────────────────────────────────────────────────────────
|
||||
private string newBmMsgId = "";
|
||||
private string newBmName = "";
|
||||
private string newBmMask = "";
|
||||
private int newBmShift = 0;
|
||||
private double newBmScale = 1.0;
|
||||
private double newBmOffset = 0.0;
|
||||
private string newBmUnit = "";
|
||||
private string? bitmaskError;
|
||||
|
||||
// ── Send form ─────────────────────────────────────────────────────────────
|
||||
private string sendIdHex = "";
|
||||
private string sendDataHex = "";
|
||||
private bool sendExtended;
|
||||
private string? sendFeedback;
|
||||
private bool sendOk;
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
filters = CanService.Filters.ToList();
|
||||
bitmasks = CanService.Bitmasks.ToList();
|
||||
CanService.MessageReceived += OnMessageReceived;
|
||||
}
|
||||
|
||||
private void OnMessageReceived(CanMessageDto msg)
|
||||
{
|
||||
lock (messages)
|
||||
{
|
||||
messages.Insert(0, msg);
|
||||
if (messages.Count > MaxMessages)
|
||||
messages.RemoveAt(messages.Count - 1);
|
||||
}
|
||||
lock (latestMessages)
|
||||
{
|
||||
latestMessages[msg.Id] = msg;
|
||||
updateCounts[msg.Id] = updateCounts.GetValueOrDefault(msg.Id) + 1;
|
||||
}
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
public void Dispose() => CanService.MessageReceived -= OnMessageReceived;
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void ClearMessages()
|
||||
{
|
||||
lock (messages) messages.Clear();
|
||||
lock (latestMessages) { latestMessages.Clear(); updateCounts.Clear(); }
|
||||
}
|
||||
|
||||
// ── Filters ───────────────────────────────────────────────────────────────
|
||||
|
||||
private void AddFilter()
|
||||
{
|
||||
filterError = null;
|
||||
if (!TryParseHex32(newFilterId, out uint msgId))
|
||||
{ filterError = "Invalid ID — enter a hex value e.g. 100"; return; }
|
||||
if (!TryParseHex32(newFilterMask, out uint mask))
|
||||
{ filterError = "Invalid mask — enter a hex value e.g. 7FF"; return; }
|
||||
|
||||
var filter = new CanFilter { MessageId = msgId, Mask = mask, Description = newFilterDesc };
|
||||
CanService.AddFilter(filter);
|
||||
filters = CanService.Filters.ToList();
|
||||
newFilterId = ""; newFilterMask = "7FF"; newFilterDesc = "";
|
||||
}
|
||||
|
||||
private void RemoveFilter(Guid id)
|
||||
{
|
||||
CanService.RemoveFilter(id);
|
||||
filters = CanService.Filters.ToList();
|
||||
}
|
||||
|
||||
private void ClearFilters()
|
||||
{
|
||||
CanService.ClearFilters();
|
||||
filters = [];
|
||||
}
|
||||
|
||||
// ── Bitmasks ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void AddBitmask()
|
||||
{
|
||||
bitmaskError = null;
|
||||
if (!TryParseHex32(newBmMsgId, out uint msgId))
|
||||
{ bitmaskError = "Invalid Msg ID"; return; }
|
||||
if (string.IsNullOrWhiteSpace(newBmName))
|
||||
{ bitmaskError = "Signal name is required"; return; }
|
||||
if (!TryParseHex64(newBmMask, out ulong mask))
|
||||
{ bitmaskError = "Invalid 64-bit mask — enter hex e.g. 00FF000000000000"; return; }
|
||||
|
||||
var bitmask = new CanBitmask
|
||||
{
|
||||
MessageId = msgId,
|
||||
SignalName = newBmName.Trim(),
|
||||
DataMask = mask,
|
||||
RightShift = newBmShift,
|
||||
Scale = newBmScale,
|
||||
Offset = newBmOffset,
|
||||
Unit = string.IsNullOrWhiteSpace(newBmUnit) ? null : newBmUnit
|
||||
};
|
||||
CanService.AddBitmask(bitmask);
|
||||
bitmasks = CanService.Bitmasks.ToList();
|
||||
newBmMsgId = ""; newBmName = ""; newBmMask = "";
|
||||
newBmShift = 0; newBmScale = 1.0; newBmOffset = 0.0; newBmUnit = "";
|
||||
}
|
||||
|
||||
private void RemoveBitmask(Guid id)
|
||||
{
|
||||
CanService.RemoveBitmask(id);
|
||||
bitmasks = CanService.Bitmasks.ToList();
|
||||
}
|
||||
|
||||
private void ClearBitmasks()
|
||||
{
|
||||
CanService.ClearBitmasks();
|
||||
bitmasks = [];
|
||||
}
|
||||
|
||||
// ── Send ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private void SendMessage()
|
||||
{
|
||||
sendFeedback = null;
|
||||
if (!TryParseHex32(sendIdHex, out uint id))
|
||||
{ sendFeedback = "Invalid ID"; sendOk = false; return; }
|
||||
|
||||
byte[] data;
|
||||
try
|
||||
{
|
||||
var hex = (sendDataHex ?? "").Replace(" ", "");
|
||||
data = hex.Length > 0 ? Convert.FromHexString(hex) : [];
|
||||
}
|
||||
catch { sendFeedback = "Invalid hex data"; sendOk = false; return; }
|
||||
|
||||
if (data.Length > 8)
|
||||
{ sendFeedback = "Max 8 bytes"; sendOk = false; return; }
|
||||
|
||||
var msg = new PcanMessage
|
||||
{
|
||||
ID = id,
|
||||
MsgType = sendExtended ? MessageType.Extended : MessageType.Standard,
|
||||
DLC = (byte)data.Length,
|
||||
Data = new byte[8]
|
||||
};
|
||||
data.CopyTo(msg.Data, 0);
|
||||
|
||||
var result = CanService.Write(msg);
|
||||
sendOk = result == PcanStatus.OK;
|
||||
sendFeedback = sendOk ? "Sent!" : $"Error: {result}";
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private List<CanMessageDto> GetLatestRows()
|
||||
{
|
||||
lock (latestMessages)
|
||||
return latestMessages.Values.OrderBy(m => m.Id).ToList();
|
||||
}
|
||||
|
||||
private static string SignalsText(CanMessageDto m)
|
||||
{
|
||||
if (m.Signals is null || m.Signals.Count == 0) return "–";
|
||||
return string.Join(", ", m.Signals.Select(kv => $"{kv.Key}={kv.Value:G4}"));
|
||||
}
|
||||
|
||||
private static bool TryParseHex32(string? s, out uint value)
|
||||
{
|
||||
value = 0;
|
||||
if (string.IsNullOrWhiteSpace(s)) return false;
|
||||
var hex = s.Trim();
|
||||
if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) hex = hex[2..];
|
||||
return uint.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out value);
|
||||
}
|
||||
|
||||
private static bool TryParseHex64(string? s, out ulong value)
|
||||
{
|
||||
value = 0;
|
||||
if (string.IsNullOrWhiteSpace(s)) return false;
|
||||
var hex = s.Trim();
|
||||
if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) hex = hex[2..];
|
||||
return ulong.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@code{
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@page "/"
|
||||
|
||||
<PageTitle>Home</PageTitle>
|
||||
|
||||
<h1>Hello, world!</h1>
|
||||
|
||||
Welcome to your new app.
|
||||
@@ -0,0 +1,5 @@
|
||||
@page "/not-found"
|
||||
@layout MainLayout
|
||||
|
||||
<h3>Not Found</h3>
|
||||
<p>Sorry, the content you are looking for does not exist.</p>
|
||||
@@ -0,0 +1,90 @@
|
||||
@page "/scott"
|
||||
|
||||
<PageTitle>Scott</PageTitle>
|
||||
|
||||
<div class="scott-wrapper">
|
||||
<div class="poo-orbit">
|
||||
<div class="poo-spin">
|
||||
<div class="poo-wobble">
|
||||
<div class="poo-pulse">
|
||||
<span class="poo-text">💩POO💩</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scott-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 70vh;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(ellipse at center, #1a0033 0%, #000 100%);
|
||||
border-radius: 12px;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.poo-orbit {
|
||||
animation: orbit 3s linear infinite;
|
||||
}
|
||||
|
||||
.poo-spin {
|
||||
animation: spin 0.4s linear infinite;
|
||||
}
|
||||
|
||||
.poo-wobble {
|
||||
animation: wobble 0.7s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.poo-pulse {
|
||||
animation: pulse 0.5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.poo-text {
|
||||
font-size: clamp(4rem, 12vw, 9rem);
|
||||
font-weight: 900;
|
||||
font-family: Impact, sans-serif;
|
||||
letter-spacing: 0.05em;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#ff0000, #ff7700, #ffff00,
|
||||
#00ff00, #0000ff, #8b00ff,
|
||||
#ff0000
|
||||
);
|
||||
background-size: 300% auto;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
animation: rainbow 0.8s linear infinite;
|
||||
filter: drop-shadow(0 0 18px #ff00ff) drop-shadow(0 0 40px #00ffff);
|
||||
display: inline-block;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
@@keyframes orbit {
|
||||
0% { transform: rotate(0deg) translateX(40px) rotate(0deg); }
|
||||
100% { transform: rotate(360deg) translateX(40px) rotate(-360deg); }
|
||||
}
|
||||
|
||||
@@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@@keyframes wobble {
|
||||
from { transform: skewX(-15deg) scaleY(0.9); }
|
||||
to { transform: skewX(15deg) scaleY(1.15); }
|
||||
}
|
||||
|
||||
@@keyframes pulse {
|
||||
from { transform: scale(0.85); }
|
||||
to { transform: scale(1.2); }
|
||||
}
|
||||
|
||||
@@keyframes rainbow {
|
||||
0% { background-position: 0% center; }
|
||||
100% { background-position: 300% center; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user