prototyped svg reports
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 5s
Tests / Go tests (push) Failing after 20s

This commit is contained in:
2026-08-20 00:35:13 -06:00
parent 4748eda41e
commit 50adc8e2de
106 changed files with 5450 additions and 46 deletions
@@ -0,0 +1,32 @@
BEGIN;
DROP VIEW mock.shop_amazon_listing_counts;
DROP VIEW mock.shop_amazon_listing_event_sequence;
DROP VIEW mock.shop_big_cartel_listing_counts;
DROP VIEW mock.shop_big_cartel_listing_event_sequence;
DROP VIEW mock.shop_ebay_listing_counts;
DROP VIEW mock.shop_ebay_listing_event_sequence;
DROP VIEW mock.shop_ecwid_listing_counts;
DROP VIEW mock.shop_ecwid_listing_event_sequence;
DROP VIEW mock.shop_etsy_listing_counts;
DROP VIEW mock.shop_etsy_listing_event_sequence;
DROP VIEW mock.shop_shopify_listing_counts;
DROP VIEW mock.shop_shopify_listing_event_sequence;
DROP VIEW mock.shop_square_online_listing_counts;
DROP VIEW mock.shop_square_online_listing_event_sequence;
DROP VIEW mock.shop_squarespace_listing_counts;
DROP VIEW mock.shop_squarespace_listing_event_sequence;
DROP VIEW mock.shop_tiktok_listing_counts;
DROP VIEW mock.shop_tiktok_listing_event_sequence;
DROP VIEW mock.shop_walmart_marketplace_listing_counts;
DROP VIEW mock.shop_walmart_marketplace_listing_event_sequence;
DROP VIEW mock.shop_wix_listing_counts;
DROP VIEW mock.shop_wix_listing_event_sequence;
DROP VIEW mock.shop_woo_commerce_listing_counts;
DROP VIEW mock.shop_woo_commerce_listing_event_sequence;
DROP VIEW mock.shop_zoho_listing_counts;
DROP VIEW mock.shop_zoho_listing_event_sequence;
COMMIT;
File diff suppressed because it is too large Load Diff
+227
View File
@@ -2,9 +2,14 @@ package reports
import ( import (
"context" "context"
"fmt"
"slices"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"ruben/inventory2/consts"
"ruben/inventory2/domains/accounts" "ruben/inventory2/domains/accounts"
"ruben/inventory2/logging" "ruben/inventory2/logging"
) )
@@ -30,3 +35,225 @@ func NewStore(logger *logging.Logger, db *pgxpool.Pool, accts *accounts.Store) *
func (db *Store) WithContext(ctx context.Context) *StoreWithContext { func (db *Store) WithContext(ctx context.Context) *StoreWithContext {
return NewStoreWithContext(ctx, db) return NewStoreWithContext(ctx, db)
} }
// TODO: prefix all these names with 'mock' since they're all mock stuff
type (
ListingCountsOverTimeReport struct {
AccountID int64
Platform accounts.Platform
ShopID string
ListingID string
Counts []ListingCountAtTime
}
ListingCountAtTime struct {
EventTimestamp *time.Time
Count int64
}
)
func (r ListingCountsOverTimeReport) MaxCount() ListingCountAtTime {
if len(r.Counts) == 0 {
return ListingCountAtTime{}
}
return slices.MaxFunc(r.Counts, func(a, b ListingCountAtTime) int {
return int(a.Count - b.Count)
})
}
func (r ListingCountsOverTimeReport) MinCount() ListingCountAtTime {
if len(r.Counts) == 0 {
return ListingCountAtTime{}
}
return slices.MinFunc(r.Counts, func(a, b ListingCountAtTime) int {
return int(a.Count - b.Count)
})
}
func (db *Store) GetListingCountsReport(ctx context.Context, acctID int64, platform accounts.Platform, shopID, listingID string) (*ListingCountsOverTimeReport, error) {
counts, err := db.GetListingCountsOverTime(ctx, acctID, platform, shopID, listingID)
if err != nil {
return nil, err
}
return &ListingCountsOverTimeReport{
AccountID: acctID,
Platform: platform,
ShopID: shopID,
ListingID: listingID,
Counts: counts,
}, nil
}
func (db *Store) GetListingCountsOverTime(ctx context.Context, acctID int64, platform accounts.Platform, shopID, listingID string) ([]ListingCountAtTime, error) {
info, ok := getMockShopSchemaInfo(platform)
if !ok {
return nil, consts.ErrNotFound
}
var counts []ListingCountAtTime
err := pgx.BeginTxFunc(ctx, db.db, pgx.TxOptions{
AccessMode: pgx.ReadOnly,
}, func(tx pgx.Tx) error {
rows, err := tx.Query(
ctx,
fmt.Sprintf(
`
SELECT
1
FROM
%s
WHERE
shop_id = @shop_id
AND listing_id = @listing_id
`,
info.listingsTable,
),
pgx.NamedArgs{
"shop_id": shopID,
"listing_id": listingID,
},
)
if err != nil {
return fmt.Errorf("failed to perform query to check the existing of the listing: %w", err)
}
if _, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int]); err != nil {
return fmt.Errorf("listing not found: %w", consts.ErrNotFound)
}
rows, err = tx.Query(
ctx,
fmt.Sprintf(
`
SELECT
event_timestamp AS eventTimestamp,
"count"
FROM
%s
WHERE
shop_id = @shop_id
AND listing_id = @listing_id
`,
info.listingCountsView,
),
pgx.NamedArgs{
"shop_id": shopID,
"listing_id": listingID,
},
)
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
if counts, err = pgx.CollectRows(rows, pgx.RowToStructByNameLax[ListingCountAtTime]); err != nil {
return fmt.Errorf("failed to scan rows: %w", err)
}
return nil
})
return counts, err
}
type mockShopSchemaInfo struct {
platform accounts.Platform
shopTable string
listingsTable string
listingCountsView string
}
func getMockShopSchemaInfo(platform accounts.Platform) (mockShopSchemaInfo, bool) {
for _, in := range getAllMockShopSchemaInfos() {
if in.platform == platform {
return in, true
}
}
return mockShopSchemaInfo{}, false
}
func getAllMockShopSchemaInfos() []mockShopSchemaInfo {
return []mockShopSchemaInfo{
{
platform: accounts.Amazon,
shopTable: "mock.shop_amazon",
listingsTable: "mock.shop_amazon_listings",
listingCountsView: "mock.shop_amazon_listing_counts",
},
{
platform: accounts.BigCartel,
shopTable: "mock.shop_big_cartel",
listingsTable: "mock.shop_big_cartel_listings",
listingCountsView: "mock.shop_big_cartel_listing_counts",
},
{
platform: accounts.Ebay,
shopTable: "mock.shop_ebay",
listingsTable: "mock.shop_ebay_listings",
listingCountsView: "mock.shop_ebay_listing_counts",
},
{
platform: accounts.Ecwid,
shopTable: "mock.shop_ecwid",
listingsTable: "mock.shop_ecwid_listings",
listingCountsView: "mock.shop_ecwid_listing_counts",
},
{
platform: accounts.Etsy,
shopTable: "mock.shop_etsy",
listingsTable: "mock.shop_etsy_listings",
listingCountsView: "mock.shop_etsy_listing_counts",
},
{
platform: accounts.Shopify,
shopTable: "mock.shop_shopify",
listingsTable: "mock.shop_shopify_listings",
listingCountsView: "mock.shop_shopify_listing_counts",
},
{
platform: accounts.SquareOnline,
shopTable: "mock.shop_square_online",
listingsTable: "mock.shop_square_online_listings",
listingCountsView: "mock.shop_square_online_listing_counts",
},
{
platform: accounts.Squarespace,
shopTable: "mock.shop_squarespace",
listingsTable: "mock.shop_squarespace_listings",
listingCountsView: "mock.shop_squarespace_listing_counts",
},
{
platform: accounts.Tiktok,
shopTable: "mock.shop_tiktok",
listingsTable: "mock.shop_tiktok_listings",
listingCountsView: "mock.shop_tiktok_listing_counts",
},
{
platform: accounts.WalmartMarketplace,
shopTable: "mock.shop_walmart_marketplace",
listingsTable: "mock.shop_walmart_marketplace_listings",
listingCountsView: "mock.shop_walmart_marketplace_listing_counts",
},
{
platform: accounts.Wix,
shopTable: "mock.shop_wix",
listingsTable: "mock.shop_wix_listings",
listingCountsView: "mock.shop_wix_listing_counts",
},
{
platform: accounts.WooCommerce,
shopTable: "mock.shop_woo_commerce",
listingsTable: "mock.shop_woo_commerce_listings",
listingCountsView: "mock.shop_woo_commerce_listing_counts",
},
{
platform: accounts.Zoho,
shopTable: "mock.shop_zoho",
listingsTable: "mock.shop_zoho_listings",
listingCountsView: "mock.shop_zoho_listing_counts",
},
}
}
+8
View File
@@ -23,3 +23,11 @@ func NewStoreWithContext(ctx context.Context, v *Store) *StoreWithContext {
func (v_ctx *StoreWithContext) GetRawShopEvents(acctID int64, platform accounts.Platform, shopID string) ([]RawShopEvent, error) { func (v_ctx *StoreWithContext) GetRawShopEvents(acctID int64, platform accounts.Platform, shopID string) ([]RawShopEvent, error) {
return v_ctx.Store.GetRawShopEvents(v_ctx.ctx, acctID, platform, shopID) return v_ctx.Store.GetRawShopEvents(v_ctx.ctx, acctID, platform, shopID)
} }
func (v_ctx *StoreWithContext) GetListingCountsReport(acctID int64, platform accounts.Platform, shopID string, listingID string) (*ListingCountsOverTimeReport, error) {
return v_ctx.Store.GetListingCountsReport(v_ctx.ctx, acctID, platform, shopID, listingID)
}
func (v_ctx *StoreWithContext) GetListingCountsOverTime(acctID int64, platform accounts.Platform, shopID string, listingID string) ([]ListingCountAtTime, error) {
return v_ctx.Store.GetListingCountsOverTime(v_ctx.ctx, acctID, platform, shopID, listingID)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+108
View File
@@ -0,0 +1,108 @@
(function(){
const defaultDebounceTimeInMs = 500;
let isReady = false
document.addEventListener('DOMContentLoaded', function() {
isReady = true
})
function onReady(fn) {
if (isReady || document.readyState === 'complete') {
fn()
} else {
document.addEventListener('DOMContentLoaded', fn)
}
}
htmx.defineExtension("resize", {
init: (api) => {
function hasResizeTrigger(elt) {
for (spec of api.getTriggerSpecs(elt)) {
if (spec.trigger === 'resize') {
return true;
}
}
return false;
}
function getDebounceTimeInMs(elt) {
const amountAttr = elt.attributes['hx-resize-debounce'];
if (!amountAttr) {
return defaultDebounceTimeInMs
}
const amountStr = amountAttr.value
if (!amountStr) {
return defaultDebounceTimeInMs
}
try {
return parseInt(amountStr);
} catch (err) {
console.warn('invalid debounce time on trigger:', trigger);
console.error(err);
return defaultDebounceTimeInMs;
}
}
let nextID = 1;
let timeoutIDs = {};
let observers = {};
function processNode(elt) {
if (!hasResizeTrigger(elt)) {
return;
}
let data = elt['hx-resize-internal-data'];
if (!data) {
data = {
id: nextID,
};
nextID += 1;
elt['hx-resize-internal-data'] = data;
}
const obs = new ResizeObserver(() => {
if (timeoutIDs[data.id]) {
return;
}
const debounceTime = getDebounceTimeInMs(elt);
timeoutIDs[data.id] = setTimeout(() => {
elt.dispatchEvent(new Event('resize'));
delete timeoutIDs[data.id];
}, debounceTime);
});
obs.observe(elt);
observers[data.id] = obs;
}
function cleanupNode(elt) {
if (!hasResizeTrigger(elt)) {
return;
}
let data = elt['hx-resize-internal-data'];
if (!data) {
return;
}
const obs = observers[data.id];
if (obs) {
obs.disconnect();
}
delete observers[data.id];
delete timeoutIDs[data.id];
}
// process nodes
htmx.on('htmx:beforeProcessNode', evt => processNode(evt.target));
htmx.on('htmx:beforeCleanupElement', evt => cleanupNode(evt.target));
onReady(function() {
document.querySelectorAll('[hx-trigger]').forEach(processNode);
})
}
});
})()
+139
View File
@@ -3,23 +3,28 @@ package accounts
import ( import (
"errors" "errors"
"fmt" "fmt"
"slices"
"strings" "strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"ruben/inventory2/consts" "ruben/inventory2/consts"
"ruben/inventory2/domains/accounts" "ruben/inventory2/domains/accounts"
"ruben/inventory2/domains/reports"
"ruben/inventory2/logging" "ruben/inventory2/logging"
"ruben/inventory2/server/auth" "ruben/inventory2/server/auth"
"ruben/inventory2/server/param" "ruben/inventory2/server/param"
"ruben/inventory2/server/response" "ruben/inventory2/server/response"
"ruben/inventory2/server/sse" "ruben/inventory2/server/sse"
"ruben/inventory2/server/ui/charts"
) )
type accountSubrouter struct { type accountSubrouter struct {
log *logging.Logger log *logging.Logger
accts *accounts.Store accts *accounts.Store
reports *reports.Store
pub *sse.UpdateNotificationPublisher pub *sse.UpdateNotificationPublisher
} }
@@ -27,11 +32,13 @@ func Routes(
r *gin.RouterGroup, r *gin.RouterGroup,
logger *logging.Logger, logger *logging.Logger,
accts *accounts.Store, accts *accounts.Store,
reports *reports.Store,
pub *sse.UpdateNotificationPublisher, pub *sse.UpdateNotificationPublisher,
) { ) {
as := &accountSubrouter{ as := &accountSubrouter{
log: logger, log: logger,
accts: accts, accts: accts,
reports: reports,
pub: pub, pub: pub,
} }
@@ -46,6 +53,9 @@ func Routes(
mockShops.PUT("/listings/:listing-id", response.Handler(as.updateMockListing)) mockShops.PUT("/listings/:listing-id", response.Handler(as.updateMockListing))
mockShops.DELETE("/listings/:listing-id", response.Handler(as.deleteMockListing)) mockShops.DELETE("/listings/:listing-id", response.Handler(as.deleteMockListing))
mockListingCharts := r.Group("/:acctID/platforms/:platform/shops/mocks/:shop-id/listings/:listing-id/charts")
mockListingCharts.GET("/counts", response.Handler(as.getCountsChartForMockListing))
syncGroups := r.Group("/:acctID/inventory/sync-groups", pub.Publish("/:acctID/inventory/sync-groups")) syncGroups := r.Group("/:acctID/inventory/sync-groups", pub.Publish("/:acctID/inventory/sync-groups"))
syncGroups.POST("", response.Handler(as.saveNewSyncGroup)) syncGroups.POST("", response.Handler(as.saveNewSyncGroup))
@@ -421,6 +431,135 @@ func (s *accountSubrouter) deleteMockListing(c *gin.Context) (response.Response,
return response.StatusNoContent(), nil return response.StatusNoContent(), nil
} }
// TODO: accept dimensions
// GET /:acctID/platforms/:platform/shops/mocks/:shop-id/charts/counts
func (s *accountSubrouter) getCountsChartForMockListing(c *gin.Context) (response.Response, error) {
s.log.Debug("ENDPOINT HIT")
acctID := auth.GetIdentity(c).Account.AccountID
var (
platform accounts.Platform
shopID string
listingID string
width float64
height float64
timezone string
)
err := param.Path("platform", param.Platform(&platform)).
Path("shop-id", param.Text(&shopID)).
Path("listing-id", param.Text(&listingID)).
Form("width", param.Float64(&width)).
Form("height", param.Float64(&height)).
Form("timezone", param.Text(&timezone)).
Unmarshal(c)
if err != nil {
return nil, err
}
if width <= 0 {
return nil, response.BadRequest().Msg("width must be positive")
}
if height <= 0 {
return nil, response.BadRequest().Msg("height must be positive")
}
report, err := s.reports.GetListingCountsReport(c, acctID, platform, shopID, listingID)
if err != nil {
return nil, fmt.Errorf("failed to get report: %w", err)
}
/*
bvs := make([]charts.BarValue, len(report.Counts))
for i, v := range report.Counts {
label := "start"
if v.EventTimestamp != nil {
label = v.EventTimestamp.Format(time.RFC822)
}
bvs[i] = charts.BarValue{
Label: label,
Value: float64(v.Count),
}
}
return response.StatusOK().
HTMLReader(charts.NewBar(bvs...).SVG().GetMarkupReader()), nil
*/
/*
lvs := make([]charts.LineValue, len(report.Counts))
for i, v := range report.Counts {
label := "initial"
if v.EventTimestamp != nil {
label = v.EventTimestamp.Format(time.RFC822)
}
lvs[i] = charts.LineValue{
Label: label,
Value: float64(v.Count),
}
}
*/
l, err := time.LoadLocation(timezone)
if err != nil {
s.log.Errorf("failed to parse time zone %s: %v; defaulting to UTC", timezone, err)
l = time.UTC
}
var times []time.Time
for _, v := range report.Counts {
if v.EventTimestamp == nil {
continue
}
times = append(times, *v.EventTimestamp)
}
var (
minTime time.Time
maxTime time.Time
initTime time.Time
)
if len(times) > 0 {
minTime = slices.MinFunc(times, func(a, b time.Time) int {
return int(a.UnixNano() - b.UnixNano())
}).In(l)
maxTime = slices.MaxFunc(times, func(a, b time.Time) int {
return int(a.UnixNano() - b.UnixNano())
}).In(l)
if len(times) > 1 {
avgIntervalPerUpdate := time.Duration((maxTime.UnixNano() - minTime.UnixNano())) / time.Duration(len(times)-1)
initTime = minTime.Add(-avgIntervalPerUpdate)
} else {
initTime = minTime.Add(-time.Minute)
}
}
lvs := make([]charts.TimeLineValue, len(report.Counts))
for i, v := range report.Counts {
ts := initTime
label := fmt.Sprint(v.Count)
if v.EventTimestamp != nil {
ts = v.EventTimestamp.In(l)
}
lvs[i] = charts.TimeLineValue{
Label: label,
Time: ts,
Value: float64(v.Count),
}
}
return response.StatusOK().
HTMLReader(
//charts.NewLineChart(width, height, lvs...).
charts.NewTimeLineChart(width, height, lvs...).
Foreground("var(--foreground)").
Background("var(--muted)").
SVG().
GetMarkupReader(),
), nil
}
// POST /:acctID/inventory/sync-groups/mock/draft/listings // POST /:acctID/inventory/sync-groups/mock/draft/listings
func (s *accountSubrouter) createMockSyncGroupListingDraft(c *gin.Context) (response.Response, error) { func (s *accountSubrouter) createMockSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
r := c.Request r := c.Request
+3
View File
@@ -6,6 +6,7 @@ import (
"ruben/inventory2/domains/accounts" "ruben/inventory2/domains/accounts"
etsy_platform "ruben/inventory2/domains/platforms/etsy" etsy_platform "ruben/inventory2/domains/platforms/etsy"
"ruben/inventory2/domains/raw_events" "ruben/inventory2/domains/raw_events"
"ruben/inventory2/domains/reports"
"ruben/inventory2/logging" "ruben/inventory2/logging"
accounts_api "ruben/inventory2/server/api/accounts" accounts_api "ruben/inventory2/server/api/accounts"
auth_api "ruben/inventory2/server/api/auth" auth_api "ruben/inventory2/server/api/auth"
@@ -22,6 +23,7 @@ func Routes(
auth *auth.Service, auth *auth.Service,
sq *sse.Queue, sq *sse.Queue,
accts *accounts.Store, accts *accounts.Store,
reps *reports.Store,
unp *sse.UpdateNotificationPublisher, unp *sse.UpdateNotificationPublisher,
rawEvents *raw_events.Store, rawEvents *raw_events.Store,
etsy *etsy_platform.Platform, etsy *etsy_platform.Platform,
@@ -40,6 +42,7 @@ func Routes(
r.Group("/accounts", auth.Authenticate()), r.Group("/accounts", auth.Authenticate()),
logger.WithGroup("/accounts"), logger.WithGroup("/accounts"),
accts, accts,
reps,
unp.Group("/accounts"), unp.Group("/accounts"),
) )
webhooks.Routes( webhooks.Routes(
+14
View File
@@ -21,6 +21,10 @@ func Int64(dst *int64) encoding.TextUnmarshaler {
return (*int64Text)(dst) return (*int64Text)(dst)
} }
func Float64(dst *float64) encoding.TextUnmarshaler {
return (*float64Text)(dst)
}
func Bool(dst *bool) encoding.TextUnmarshaler { func Bool(dst *bool) encoding.TextUnmarshaler {
return (*boolText)(dst) return (*boolText)(dst)
} }
@@ -33,6 +37,7 @@ type (
rawText string rawText string
intText int intText int
int64Text int64 int64Text int64
float64Text float64
boolText bool boolText bool
platformText accounts.Platform platformText accounts.Platform
) )
@@ -60,6 +65,15 @@ func (n *int64Text) UnmarshalText(text []byte) error {
return nil return nil
} }
func (n *float64Text) UnmarshalText(text []byte) error {
i, err := strconv.ParseFloat(string(text), 64)
if err != nil {
return err
}
*n = float64Text(i)
return nil
}
func (b *boolText) UnmarshalText(text []byte) error { func (b *boolText) UnmarshalText(text []byte) error {
v, err := strconv.ParseBool(string(text)) v, err := strconv.ParseBool(string(text))
if err != nil { if err != nil {
+2
View File
@@ -65,8 +65,10 @@ func (s Spec) Unmarshal(c *gin.Context) error {
for k, dst := range s.form { for k, dst := range s.form {
v, ok := c.GetPostForm(k) v, ok := c.GetPostForm(k)
if !ok || v == "" { if !ok || v == "" {
if v, ok = c.GetQuery(k); !ok {
return response.BadRequest().Msgf("no %s provided", k) return response.BadRequest().Msgf("no %s provided", k)
} }
}
if err := dst.UnmarshalText([]byte(v)); err != nil { if err := dst.UnmarshalText([]byte(v)); err != nil {
return response.BadRequest().Wrap(err).Msgf("invalid %s provided", k) return response.BadRequest().Wrap(err).Msgf("invalid %s provided", k)
} }
+4
View File
@@ -47,6 +47,10 @@ func (b bodyRes) HTML(body []byte) Response {
return HTML(body).wrap(b) return HTML(body).wrap(b)
} }
func (b bodyRes) HTMLReader(body io.Reader) Response {
return HTMLReader(body).wrap(b)
}
func (b bodyRes) JSON(body any) Response { func (b bodyRes) JSON(body any) Response {
return JSON(body).wrap(b) return JSON(body).wrap(b)
} }
+4
View File
@@ -47,6 +47,10 @@ func (c cookieRes) HTML(body []byte) Response {
return HTML(body).wrap(c) return HTML(body).wrap(c)
} }
func (c cookieRes) HTMLReader(body io.Reader) Response {
return HTMLReader(body).wrap(c)
}
func (c cookieRes) JSON(body any) Response { func (c cookieRes) JSON(body any) Response {
return JSON(body).wrap(c) return JSON(body).wrap(c)
} }
+4
View File
@@ -103,6 +103,10 @@ func (h headerRes) HTML(body []byte) Response {
return HTML(body).wrap(h) return HTML(body).wrap(h)
} }
func (h headerRes) HTMLReader(body io.Reader) Response {
return HTMLReader(body).wrap(h)
}
func (h headerRes) JSON(body any) Response { func (h headerRes) JSON(body any) Response {
return JSON(body).wrap(h) return JSON(body).wrap(h)
} }
+21 -1
View File
@@ -12,6 +12,7 @@ import (
type ( type (
htmlRes struct { htmlRes struct {
body []byte body []byte
reader io.Reader
res Response res Response
} }
) )
@@ -24,6 +25,12 @@ func HTML(body []byte) Response {
} }
} }
func HTMLReader(body io.Reader) Response {
return htmlRes{
reader: body,
}
}
func (h htmlRes) String() string { func (h htmlRes) String() string {
if h.res != nil { if h.res != nil {
return fmt.Sprintf(`{"body": %q, "nested": %s}`, string(h.body), h.res) return fmt.Sprintf(`{"body": %q, "nested": %s}`, string(h.body), h.res)
@@ -46,6 +53,13 @@ func (h htmlRes) Redirect(code redirect.Code, to string) Response {
func (h htmlRes) HTML(body []byte) Response { func (h htmlRes) HTML(body []byte) Response {
h.body = body h.body = body
h.reader = nil
return h
}
func (h htmlRes) HTMLReader(body io.Reader) Response {
h.body = nil
h.reader = body
return h return h
} }
@@ -133,7 +147,13 @@ func (h htmlRes) GetRedirect() (code redirect.Code, to string, ok bool) {
} }
func (h htmlRes) getBody() (body io.ReadCloser, ok bool, err error) { func (h htmlRes) getBody() (body io.ReadCloser, ok bool, err error) {
return io.NopCloser(bytes.NewBuffer(h.body)), true, nil var r io.Reader
if h.reader != nil {
r = h.reader
} else {
r = bytes.NewBuffer(h.body)
}
return io.NopCloser(r), true, nil
} }
func (h htmlRes) getCookies() []http.Cookie { func (h htmlRes) getCookies() []http.Cookie {
+4
View File
@@ -49,6 +49,10 @@ func (j jsonRes) HTML(body []byte) Response {
return HTML(body).wrap(j) return HTML(body).wrap(j)
} }
func (j jsonRes) HTMLReader(body io.Reader) Response {
return HTMLReader(body).wrap(j)
}
func (j jsonRes) JSON(body any) Response { func (j jsonRes) JSON(body any) Response {
j.body = body j.body = body
return j return j
+4
View File
@@ -88,6 +88,10 @@ func (r redirectRes) HTML(body []byte) Response {
return HTML(body).wrap(r) return HTML(body).wrap(r)
} }
func (r redirectRes) HTMLReader(body io.Reader) Response {
return HTMLReader(body).wrap(r)
}
func (r redirectRes) JSON(body any) Response { func (r redirectRes) JSON(body any) Response {
return JSON(body).wrap(r) return JSON(body).wrap(r)
} }
+1
View File
@@ -25,6 +25,7 @@ type (
Redirect(code redirect.Code, to string) Response Redirect(code redirect.Code, to string) Response
Body(io.ReadCloser) Response Body(io.ReadCloser) Response
HTML([]byte) Response HTML([]byte) Response
HTMLReader(io.Reader) Response
JSON(any) Response JSON(any) Response
Cookie(http.Cookie) Response Cookie(http.Cookie) Response
+4
View File
@@ -64,6 +64,10 @@ func (s statusRes) HTML(body []byte) Response {
return HTML(body).wrap(s) return HTML(body).wrap(s)
} }
func (s statusRes) HTMLReader(body io.Reader) Response {
return HTMLReader(body).wrap(s)
}
func (s statusRes) JSON(body any) Response { func (s statusRes) JSON(body any) Response {
return JSON(body).wrap(s) return JSON(body).wrap(s)
} }
+1
View File
@@ -95,6 +95,7 @@ func NewRouter(
authM, authM,
sq, sq,
accts, accts,
reps,
unp, unp,
rawEvents, rawEvents,
etsy, etsy,
+159
View File
@@ -0,0 +1,159 @@
package charts
import (
"fmt"
"slices"
"strings"
"ruben/inventory2/server/ui/svg"
)
type (
// TODO: rename to BarChart
Bar struct {
values []BarValue
classes barChartClasses
}
BarValue struct {
Label string
Value float64
}
barChartClasses struct {
svg []string
bar []string
}
)
func NewBar(vs ...BarValue) *Bar {
return &Bar{
values: vs,
}
}
func (c *Bar) WithSVGClass(classes ...string) *Bar {
c.classes.svg = append(c.classes.svg, classes...)
return c
}
func (c *Bar) WithBarClass(classes ...string) *Bar {
c.classes.bar = append(c.classes.bar, classes...)
return c
}
func (c *Bar) SVG() svg.MarshalerReader {
const (
minX = 0
minY = 0
maxX = 100
maxY = 100
rangeX = maxX - minX
rangeY = maxY - minY
)
s := svg.NewSVG().
Attr(
svg.Style{
"width": "100%",
"height": "auto",
"padding": "1em",
"border-width": "2px",
},
svg.ViewBox{
X: minX,
Y: minY,
Width: maxX - minX,
Height: maxY - minY,
},
svg.Class(
strings.Join(append(
[]string{"rounded-lg", "border-border"},
c.classes.bar...,
), " "),
),
)
if len(c.classes.svg) != 0 {
s = s.Attr(svg.Class(strings.Join(c.classes.svg, " ")))
}
if len(c.values) > 0 {
minV := slices.MinFunc(c.values, func(a, b BarValue) int {
if a.Value < b.Value {
return -1
}
if a.Value > b.Value {
return 1
}
return 0
})
maxV := slices.MaxFunc(c.values, func(a, b BarValue) int {
if a.Value < b.Value {
return -1
}
if a.Value > b.Value {
return 1
}
return 0
})
rangeV := maxV.Value - minV.Value
spacePerBar := float64(rangeX) / float64(len(c.values))
barPadding := 0.025 * spacePerBar
barWidth := spacePerBar - (2 * barPadding)
fontSize := barWidth / 2
for i, v := range c.values {
scaledV := (v.Value - minV.Value) * rangeY / rangeV
barX := float64(i)*spacePerBar + barPadding
s = s.Child(
new(svg.Rect).
Attr(
svg.NewLength(barX).AsX(),
svg.NewLength(maxY-scaledV).AsY(),
svg.NewLength(barWidth).AsWidth(),
svg.NewLength(scaledV).AsHeight(),
svg.Class(
strings.Join(append(
[]string{"fill-accent"},
c.classes.bar...,
), " "),
),
),
svg.NewText(fmt.Sprint(v.Value)).
Attr(
svg.NewLength(barX+(barWidth/2)).AsX(),
svg.NewLength(maxY-scaledV+fontSize).AsY(),
svg.Fill("var(--accent-foreground)"),
svg.TextAnchorMiddle,
// svg.NewLength(fontSize).AsFontSize(),
svg.Style{
"font-size": fmt.Sprintf("%vpx", fontSize),
},
),
)
if v.Label != "" {
x := barX + (barWidth / 2)
y := maxY - fontSize
s = s.Child(
svg.NewText(v.Label).
Attr(
svg.NewLength(x).AsX(),
svg.NewLength(y).AsY(),
svg.Fill("var(--accent-foreground)"),
//svg.NewLength(fontSize).AsFontSize(),
svg.Style{
"font-size": fmt.Sprintf("%vpx", fontSize),
},
svg.DominantBaselineCentral,
svg.TransformRotate(-90).About(x, y),
),
)
}
}
}
return s
}
+95
View File
@@ -0,0 +1,95 @@
package charts
import (
"fmt"
"os"
)
func ExampleBar() {
mu := NewBar(
BarValue{
Label: "label: 101",
Value: 101,
},
BarValue{
Label: "label: -09",
Value: -99,
},
BarValue{
Label: "label: 201",
Value: 201,
},
BarValue{
Label: "label: -199",
Value: -199,
},
BarValue{
Label: "label: 301",
Value: 301,
},
BarValue{
Label: "label: -299",
Value: -299,
},
BarValue{
Label: "label: 401",
Value: 401,
},
BarValue{
Label: "label: -399",
Value: -399,
},
BarValue{
Label: "label: 501",
Value: 501,
},
BarValue{
Label: "label: -499",
Value: -499,
},
BarValue{
Label: "label: 601",
Value: 601,
},
BarValue{
Label: "label: -599",
Value: -599,
},
BarValue{
Label: "label: 701",
Value: 701,
},
BarValue{
Label: "label: -699",
Value: -699,
},
BarValue{
Label: "label: 801",
Value: 801,
},
BarValue{
Label: "label: -799",
Value: -799,
},
BarValue{
Label: "label: 901",
Value: 901,
},
BarValue{
Label: "label: -899",
Value: -899,
},
BarValue{
Label: "label: 1001",
Value: 1001,
},
BarValue{
Label: "label: -999",
Value: -999,
},
).SVG().GetMarkup()
os.WriteFile("./test.svg", []byte(mu), 666)
fmt.Println(mu)
// Output: <svg version="1.1" xmlns="http://www.w3.org/2000/svg" style="height: auto; width: 100%" viewBox="0 0 100 100"><rect x="0" y="45" width="4.75" height="55" class="fill-accent"/><rect x="5" y="55" width="4.75" height="45" class="fill-accent"/><rect x="10" y="40" width="4.75" height="60" class="fill-accent"/><rect x="15" y="60" width="4.75" height="40" class="fill-accent"/><rect x="20" y="35" width="4.75" height="65" class="fill-accent"/><rect x="25" y="65" width="4.75" height="35" class="fill-accent"/><rect x="30" y="30" width="4.75" height="70" class="fill-accent"/><rect x="35" y="70" width="4.75" height="30" class="fill-accent"/><rect x="40" y="25" width="4.75" height="75" class="fill-accent"/><rect x="45" y="75" width="4.75" height="25" class="fill-accent"/><rect x="50" y="20" width="4.75" height="80" class="fill-accent"/><rect x="55" y="80" width="4.75" height="20" class="fill-accent"/><rect x="60" y="15" width="4.75" height="85" class="fill-accent"/><rect x="65" y="85" width="4.75" height="15" class="fill-accent"/><rect x="70" y="10" width="4.75" height="90" class="fill-accent"/><rect x="75" y="90" width="4.75" height="10" class="fill-accent"/><rect x="80" y="5" width="4.75" height="95" class="fill-accent"/><rect x="85" y="95" width="4.75" height="5" class="fill-accent"/><rect x="90" y="0" width="4.75" height="100" class="fill-accent"/><rect x="95" y="100" width="4.75" height="0" class="fill-accent"/></svg>
}
+236
View File
@@ -0,0 +1,236 @@
package charts
import (
"ruben/inventory2/server/ui/svg"
"slices"
)
type (
LineChart struct {
width float64
height float64
foreground string
background string
values []LineValue
max *float64
min *float64
}
LineValue struct {
Label string
Value float64
}
)
func NewLineChart(width, height float64, vs ...LineValue) *LineChart {
return &LineChart{
width: width,
height: height,
values: vs,
}
}
func (c *LineChart) Max(n float64) *LineChart {
c.max = &n
return c
}
func (c *LineChart) Min(n float64) *LineChart {
c.min = &n
return c
}
func (c *LineChart) Foreground(fg string) *LineChart {
c.foreground = fg
return c
}
func (c *LineChart) Background(bg string) *LineChart {
c.background = bg
return c
}
func (c *LineChart) SVG() svg.MarshalerReader {
var (
minVal float64
maxVal float64
)
fg := "black"
if c.foreground != "" {
fg = c.foreground
}
bg := c.background
if c.min != nil {
minVal = *c.min
} else if len(c.values) > 0 {
minVal = slices.MinFunc(c.values, compareLineValues).Value
}
if c.max != nil {
maxVal = *c.max
} else if len(c.values) > 0 {
maxVal = slices.MaxFunc(c.values, compareLineValues).Value
}
svgBg := bg
if svgBg == "" {
svgBg = "auto"
}
s := svg.NewSVG().
Attr(
svg.NewLength(c.width).
AsWidth(),
svg.NewLength(c.height).
AsHeight(),
svg.Style{
// "width": "100%",
// "height": "100%",
"background": svgBg,
},
/*
svg.ViewBox{
X: 0,
Y: minVal,
Width: float64(len(c.values) - 1),
Height: maxVal - minVal,
},
*/
svg.ViewBox{
X: 0,
Y: 0,
Width: c.width,
Height: c.height,
},
svg.PreserveAspectRatio{},
)
g := new(svg.G).
Attr(
// getChartOrientationTransform(c.height),
svg.Transform{
// scale down to provide padding
svg.TransformTranslate{
X: 0.05 * c.width,
Y: 0.05 * c.height,
},
svg.TransformScale{
X: 0.9,
Y: 0.9,
},
// flip
svg.TransformScale{
X: 1,
Y: -1,
},
svg.TransformTranslate{
Y: -c.height,
},
},
)
// lines
pts := make(svg.Points, len(c.values))
for i, p := range c.values {
pts[i] = svg.Point{
X: (c.width / float64(len(c.values)-1)) * float64(i),
Y: ((p.Value - minVal) * c.height) / (maxVal - minVal),
}
}
g = g.Child(
new(svg.Polyline).Attr(
pts,
svg.NewLength(2).
Unit(svg.Px).
AsStrokeWidth(),
svg.VectorEffectNonScalingStroke,
svg.Stroke(fg),
svg.Fill("none"),
),
)
// points
if len(c.values) > 0 {
r := 1.0 / float64(len(c.values))
rx := r
ry := r
for i, p := range c.values {
// dot on the line graph
x := pts[i].X
y := pts[i].Y
ell := new(svg.Ellipse).
Attr(
svg.NewLength(x).
AsCX(),
svg.NewLength(y).
AsCY(),
svg.Percentage(rx).
AsRX(),
svg.Percentage(ry).
AsRY(),
svg.Stroke(fg),
svg.NewLength(2).
Unit(svg.Px).
AsStrokeWidth(),
svg.VectorEffectNonScalingStroke,
)
if bg != "" {
ell = ell.Attr(
svg.Fill(bg),
)
} else {
ell = ell.Attr(
svg.Fill(fg),
)
}
g = g.Child(
ell,
// label
upsideDownCenteredText(p.Label, x, y).
Attr(
svg.NewLength(c.height*0.05).
Unit(svg.Px).
AsFontSize(),
),
)
}
}
return s.Child(g)
}
func compareLineValues(a, b LineValue) int {
if a.Value < b.Value {
return -1
}
if a.Value > b.Value {
return 1
}
return 0
}
func upsideDownCenteredText(txt string, x, y float64) *svg.Text {
return svg.NewText(txt).
Attr(
svg.TextAnchorMiddle,
svg.DominantBaselineMiddle,
svg.NewLength(x).
AsX(),
svg.NewLength(y).
AsY(),
upsideDownTransform(x, y),
)
}
+40
View File
@@ -0,0 +1,40 @@
package charts
import (
"fmt"
"os"
)
func ExampleLineChart() {
c := NewLineChart(100, 100,
LineValue{
Value: 1,
},
LineValue{
Value: 2,
},
LineValue{
Value: 4,
},
LineValue{
Value: 8,
},
LineValue{
Value: 16,
},
LineValue{
Value: 32,
},
).
Min(0).
Max(40).
Foreground("purple").
Background("pink").
SVG().
GetMarkup()
output := []byte(`<html><body>` + c + `</body></html>`)
os.WriteFile("./line_chart.html", []byte(output), 0666)
fmt.Println(c)
// Output: <svg version="1.1" xmlns="http://www.w3.org/2000/svg" style="height: auto; width: 100%" viewBox="0 0 100 100"></svg>
}
+32
View File
@@ -0,0 +1,32 @@
package charts
import "ruben/inventory2/server/ui/svg"
func getChartOrientationTransform(height float64) svg.Transform {
return svg.Transform{
svg.TransformScale{
X: 1,
Y: -1,
},
svg.TransformTranslate{
Y: -height,
},
}
}
func upsideDownTransform(x, y float64) svg.Transform {
return svg.Transform{
svg.TransformTranslate{
X: x,
Y: y,
},
svg.TransformScale{
X: 1,
Y: -1,
},
svg.TransformTranslate{
X: -x,
Y: -y,
},
}
}
+1
View File
@@ -0,0 +1 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" style="height: auto; width: 100%" viewBox="0 0 100 100"><rect x="0" y="45" width="4.75" height="55" class="fill-accent"/><rect x="5" y="55" width="4.75" height="45" class="fill-accent"/><rect x="10" y="40" width="4.75" height="60" class="fill-accent"/><rect x="15" y="60" width="4.75" height="40" class="fill-accent"/><rect x="20" y="35" width="4.75" height="65" class="fill-accent"/><rect x="25" y="65" width="4.75" height="35" class="fill-accent"/><rect x="30" y="30" width="4.75" height="70" class="fill-accent"/><rect x="35" y="70" width="4.75" height="30" class="fill-accent"/><rect x="40" y="25" width="4.75" height="75" class="fill-accent"/><rect x="45" y="75" width="4.75" height="25" class="fill-accent"/><rect x="50" y="20" width="4.75" height="80" class="fill-accent"/><rect x="55" y="80" width="4.75" height="20" class="fill-accent"/><rect x="60" y="15" width="4.75" height="85" class="fill-accent"/><rect x="65" y="85" width="4.75" height="15" class="fill-accent"/><rect x="70" y="10" width="4.75" height="90" class="fill-accent"/><rect x="75" y="90" width="4.75" height="10" class="fill-accent"/><rect x="80" y="5" width="4.75" height="95" class="fill-accent"/><rect x="85" y="95" width="4.75" height="5" class="fill-accent"/><rect x="90" y="0" width="4.75" height="100" class="fill-accent"/><rect x="95" y="100" width="4.75" height="0" class="fill-accent"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+425
View File
@@ -0,0 +1,425 @@
package charts
import (
"ruben/inventory2/server/ui/svg"
"slices"
"time"
)
type (
TimeLineChart struct {
width float64
height float64
foreground string
background string
values []TimeLineValue
max *float64
min *float64
}
TimeLineValue struct {
Label string
Time time.Time
Value float64
}
)
func NewTimeLineChart(width, height float64, vs ...TimeLineValue) *TimeLineChart {
return &TimeLineChart{
width: width,
height: height,
values: validValues(vs),
}
}
func validValues(vs []TimeLineValue) []TimeLineValue {
values := make([]TimeLineValue, 0, len(vs))
for _, v := range vs {
if !v.Time.IsZero() {
values = append(values, v)
}
}
return values
}
func (c *TimeLineChart) Max(n float64) *TimeLineChart {
c.max = &n
return c
}
func (c *TimeLineChart) Min(n float64) *TimeLineChart {
c.min = &n
return c
}
func (c *TimeLineChart) Foreground(fg string) *TimeLineChart {
c.foreground = fg
return c
}
func (c *TimeLineChart) Background(bg string) *TimeLineChart {
c.background = bg
return c
}
func (c *TimeLineChart) SVG() svg.MarshalerReader {
return svg.NewSVG().
Attr(
svg.NewLength(c.width).
AsWidth(),
svg.NewLength(c.height).
AsHeight(),
svg.Style{
"background": c.getOuterSVGBackground(),
},
svg.ViewBox{
X: 0,
Y: 0,
Width: c.width,
Height: c.height,
},
svg.PreserveAspectRatio{},
).
Child(
new(svg.G).
Attr(
svg.Transform{
// scale down to provide padding
svg.TransformTranslate{
X: 0.05 * c.width,
Y: 0.05 * c.height,
},
svg.TransformScale{
X: 0.9,
Y: 0.85, // a little more padding on the bottom
},
// flip
svg.TransformScale{
X: 1,
Y: -1,
},
svg.TransformTranslate{
Y: -c.height,
},
},
).
Child(c.getChildren()...),
)
}
func (c *TimeLineChart) getOuterSVGBackground() string {
if c.background != "" {
return c.background
}
return "auto"
}
func (c *TimeLineChart) getChildren() []svg.GChildren {
pts := c.getPoints()
return append(
append(
[]svg.GChildren{
new(svg.Polyline).Attr(
pts,
svg.NewLength(2).
Unit(svg.Px).
AsStrokeWidth(),
svg.VectorEffectNonScalingStroke,
svg.Stroke(c.getStroke()),
svg.Fill("none"),
),
},
c.buildDots(pts)...,
),
c.buildLabels(pts)...,
)
}
func (c *TimeLineChart) getPoints() svg.Points {
minVal, _ := c.getMinVal()
maxVal, _ := c.getMaxVal()
minTime, maxTime := c.getMinAndMaxUnixTimes()
pts := make(svg.Points, len(c.values))
for i, p := range c.values {
pts[i] = svg.Point{
X: c.width * float64(p.Time.UnixNano()-minTime) / float64(maxTime-minTime),
Y: ((p.Value - minVal) * c.height) / (maxVal - minVal),
}
}
return pts
}
func (c *TimeLineChart) getMinVal() (float64, bool) {
if c.min != nil {
return *c.min, true
}
if values := c.values; len(values) > 0 {
return slices.MinFunc(values, compareTimeLineValues).Value, true
}
return 0, false
}
func (c *TimeLineChart) getMaxVal() (float64, bool) {
if c.max != nil {
return *c.max, true
}
if len(c.values) > 0 {
return slices.MaxFunc(c.values, compareTimeLineValues).Value, true
}
return 0, false
}
func compareTimeLineValues(a, b TimeLineValue) int {
if a.Value < b.Value {
return -1
}
if a.Value > b.Value {
return 1
}
return 0
}
func (c *TimeLineChart) getMinAndMaxUnixTimes() (minTime, maxTime int64) {
if len(c.values) > 0 {
return slices.MinFunc(c.values, compareTimeLineTimeValues).Time.UnixNano(),
slices.MaxFunc(c.values, compareTimeLineTimeValues).Time.UnixNano()
}
return 0, 0
}
func compareTimeLineTimeValues(a, b TimeLineValue) int {
at := a.Time
bt := b.Time
an := at.UnixNano()
bn := bt.UnixNano()
if at.IsZero() {
an = 0
}
if bt.IsZero() {
bn = 0
}
return int(an - bn)
}
func (c *TimeLineChart) buildDots(pts svg.Points) []svg.GChildren {
if len(c.values) == 0 {
return nil
}
chn := make([]svg.GChildren, len(c.values))
for i, p := range pts {
chn[i] = c.buildDot(p)
}
return chn
}
func (c *TimeLineChart) buildDot(p svg.Point) *svg.Circle {
return new(svg.Circle).
Attr(
svg.NewLength(p.X).
AsCX(),
svg.NewLength(p.Y).
AsCY(),
svg.NewLength(0.01*min(c.width, c.height)).
AsR(),
svg.Stroke(c.getStroke()),
svg.NewLength(2).
Unit(svg.Px).
AsStrokeWidth(),
svg.VectorEffectNonScalingStroke,
c.getDotFill(),
)
}
func (c *TimeLineChart) getDotFill() svg.Fill {
if c.background != "" {
return svg.Fill(c.background)
} else {
return svg.Fill(c.getStroke())
}
}
func (c *TimeLineChart) buildLabels(pts svg.Points) []svg.GChildren {
if len(c.values) == 0 {
return nil
}
chn := make([]svg.GChildren, len(c.values))
for i, p := range pts {
chn[i] = c.buildLabel(c.values[i], p)
}
return chn
}
func (c *TimeLineChart) buildLabel(v TimeLineValue, p svg.Point) *svg.Element[svg.SVGTag, svg.SVGAttribute, svg.SVGChildren] {
return svg.NewSVG().
Attr(
svg.NewLength(p.X).
AsX(),
svg.NewLength(p.Y).
AsY(),
svg.NewLength(24).
Unit(svg.Px).
AsWidth(),
svg.NewLength(24).
Unit(svg.Px).
AsHeight(),
svg.Class("group"),
svg.Style{
"overflow": "visible",
"fill": "var(--accent-foreground)",
},
svg.ViewBox{
X: 0,
Y: 0,
Width: 1,
Height: 1,
},
svg.PreserveAspectRatio{
Align: &svg.AlignValue{
X: svg.AlignMid,
Y: svg.AlignMid,
},
},
).
Child(
c.buildAlwaysDisplayedLabelText(v, p),
new(svg.Rect).
Attr(
svg.NewLength(0.1).
AsStrokeWidth(),
svg.Fill("var(--muted)"),
svg.Stroke("var(--accent-foreground)"),
svg.NewLength(2.5).
AsHeight(),
svg.NewLength(-5).
AsX(),
svg.NewLength(-3).
AsY(),
svg.NewLength(10).
AsWidth(),
svg.Class("not-group-hover:hidden"),
),
c.buildHoverLabelText(v, p),
)
}
func (c *TimeLineChart) buildAlwaysDisplayedLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] {
return c.buildLabelText(v, p)
}
func (c *TimeLineChart) buildHoverLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] {
labelText := c.buildLabelText(v, p).
Attr(
svg.Class("not-group-hover:hidden"),
)
if v.Time.IsZero() {
return labelText
}
return labelText.Child(
svg.NewText(v.Time.Format("Jan _2 3:04:05 PM")).
Attr(
svg.TextAnchorMiddle,
svg.DominantBaselineMiddle,
svg.Style{
"font-size": "1px",
},
svg.Class("not-group-hover:hidden"),
svg.NewLength(0).
AsX(),
svg.NewLength(2.5).
AsY(),
),
)
}
func (c *TimeLineChart) buildLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] {
return new(svg.G).
Attr(
svg.NewLength(0).
AsX(),
svg.NewLength(0).
AsY(),
upsideDownTransform(0, 0),
).
Child(
svg.NewText(v.Label).
Attr(
svg.TextAnchorMiddle,
svg.DominantBaselineMiddle,
svg.Style{
"font-size": "1px",
},
svg.NewLength(0).
AsX(),
svg.NewLength(1.25).
AsY(),
),
)
}
/*
func (c *TimeLineChart) buildLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] {
labelText := new(svg.G).
Attr(
svg.NewLength(0).
AsX(),
svg.NewLength(0).
AsY(),
upsideDownTransform(0, 0),
).
Child(
svg.NewText(v.Label).
Attr(
svg.TextAnchorMiddle,
svg.DominantBaselineMiddle,
svg.Style{
"font-size": "1px",
},
svg.NewLength(0).
AsX(),
svg.NewLength(1.25).
AsY(),
),
)
if v.Time.IsZero() {
return labelText
}
return labelText.Child(
svg.NewText(v.Time.Format("Jan _2 3:04:05 PM")).
Attr(
svg.TextAnchorMiddle,
svg.DominantBaselineMiddle,
svg.Style{
"font-size": "1px",
},
svg.Class("not-group-hover:hidden"),
svg.NewLength(0).
AsX(),
svg.NewLength(2.5).
AsY(),
),
)
}
*/
func (c *TimeLineChart) getStroke() string {
if c.foreground != "" {
return c.foreground
}
return "black"
}
+34 -1
View File
@@ -42,7 +42,7 @@ type (
func Routes( func Routes(
logger *logging.Logger, logger *logging.Logger,
r gin.IRoutes, r gin.IRouter,
uiPath string, uiPath string,
rawEvents *raw_events.Store, rawEvents *raw_events.Store,
accts *accounts.Store, accts *accounts.Store,
@@ -101,6 +101,39 @@ func Routes(
"multInt": func(a, b int) int { "multInt": func(a, b int) int {
return a * b return a * b
}, },
"divInt": func(a, b int) int {
return a / b
},
"intToFloat": func(n int) float64 {
return float64(n)
},
"addInt64": func(a, b int64) int64 {
return a + b
},
"subInt64": func(a, b int64) int64 {
return a - b
},
"multInt64": func(a, b int64) int64 {
return a * b
},
"divInt64": func(a, b int64) int64 {
return a / b
},
"int64ToFloat": func(n int64) float64 {
return float64(n)
},
"addFloat": func(a, b float64) float64 {
return a + b
},
"subFloat": func(a, b float64) float64 {
return a - b
},
"multFloat": func(a, b float64) float64 {
return a * b
},
"divFloat": func(a, b float64) float64 {
return a / b
},
// html // html
"rawHTML": func(s string) template.HTML { "rawHTML": func(s string) template.HTML {
+24
View File
@@ -0,0 +1,24 @@
package svg
import (
"fmt"
)
type (
// Attribute is the minimum method set of of an svg attribute.
Attribute interface {
// PrintKey must return the svg attribute key
PrintKey() string
// PrintVAlue must return the svg attribute value string, if applicable
PrintValue() (string, bool)
}
)
func PrintAttribute(a Attribute) string {
v, ok := a.PrintValue()
if !ok {
return a.PrintKey()
}
return fmt.Sprintf("%s=%s", a.PrintKey(), v)
}
+49
View File
@@ -0,0 +1,49 @@
package svg
type (
Circle = VoidElement[CircleTag, CircleAttribute]
CircleAttribute interface {
Attribute
IsCircleAttribute()
}
CircleTag struct{}
)
func (CircleTag) PrintTag() string {
return "circle"
}
func (CX) IsCircleAttribute() {
}
func (CXP) IsCircleAttribute() {
}
func (CY) IsCircleAttribute() {
}
func (CYP) IsCircleAttribute() {
}
func (R) IsCircleAttribute() {
}
func (RP) IsCircleAttribute() {
}
func (PathLength) IsCircleAttribute() {
}
func (Fill) IsCircleAttribute() {
}
func (Stroke) IsCircleAttribute() {
}
func (StrokeWidth) IsCircleAttribute() {
}
func (VectorEffect) IsCircleAttribute() {
}
+18
View File
@@ -0,0 +1,18 @@
package svg
import (
"fmt"
"html"
)
type (
Class string
)
func (Class) PrintKey() string {
return "class"
}
func (c Class) PrintValue() (string, bool) {
return fmt.Sprintf(`"%s"`, html.EscapeString(string(c))), true
}
+24
View File
@@ -0,0 +1,24 @@
package svg
type (
CX struct {
*LengthAttr[CXTag]
}
CXP struct {
PercentageAttr[CXTag]
}
CXTag struct{}
)
func (l *Length) AsCX() CX {
return CX{LengthAttr: (*LengthAttr[CXTag])(l)}
}
func (p Percentage) AsCX() CXP {
return CXP{PercentageAttr: (PercentageAttr[CXTag])(p)}
}
func (CXTag) PrintTag() string {
return "cx"
}
+24
View File
@@ -0,0 +1,24 @@
package svg
type (
CY struct {
*LengthAttr[CYTag]
}
CYP struct {
PercentageAttr[CYTag]
}
CYTag struct{}
)
func (l *Length) AsCY() CY {
return CY{LengthAttr: (*LengthAttr[CYTag])(l)}
}
func (p Percentage) AsCY() CYP {
return CYP{PercentageAttr: (PercentageAttr[CYTag])(p)}
}
func (CYTag) PrintTag() string {
return "cy"
}
+217
View File
@@ -0,0 +1,217 @@
package svg
import (
"fmt"
"strings"
)
type (
D []PathCommand
PathCommand interface {
PrintPathCommand() (string, string, bool)
PrintPathCommandParameters() string
}
// PathCommand implementations
// PathRelative makes any PathCommand and converts it into a relative command (makes lowercase)
PathRelative[C PathCommand] struct {
Command C
}
PathMoveTo struct {
X, Y float64
}
PathLineTo struct {
X, Y float64
}
PathHorizontalLine float64
PathVerticalLine float64
PathCubicBezier []PathCubicBezierParameterTuple
PathCubicBezierParameterTuple struct {
X1, Y1 float64
X2, Y2 float64
X, Y float64
}
PathSmoothCubicBezier []PathSmoothCubicBezierParameterTuple
PathSmoothCubicBezierParameterTuple struct {
X2, Y2 float64
X, Y float64
}
PathQuadraticBezier []PathQuadraticBezierParameterTuple
PathQuadraticBezierParameterTuple struct {
X1, Y1 float64
X, Y float64
}
PathSmoothQuadraticBezier []PathSmoothQuadraticBezierParameterTuple
PathSmoothQuadraticBezierParameterTuple struct {
X, Y float64
}
PathElliptical []PathEllipticalParameterTuple
PathEllipticalParameterTuple struct {
RX, RY float64
Angle float64
LargeArc bool
Clockwise bool
X, Y float64
}
PathClose struct{}
)
func PrintPathCommand(c PathCommand) (string, bool) {
cmd, params, ok := c.PrintPathCommand()
if !ok {
return "", false
}
return fmt.Sprintf("%s %s", cmd, params), true
}
func (c PathRelative[C]) PrintPathCommand() (string, string, bool) {
cmd, params, ok := c.Command.PrintPathCommand()
return strings.ToLower(cmd), params, ok
}
func (c PathMoveTo) PrintPathCommand() (string, string, bool) {
return "M", fmt.Sprintf("%v,%v", c.X, c.Y), true
}
func (c PathLineTo) PrintPathCommand() (string, string, bool) {
return "L", fmt.Sprintf("%v,%v", c.X, c.Y), true
}
func (c PathHorizontalLine) PrintPathCommand() (string, string, bool) {
return "H", fmt.Sprintf("%v", float64(c)), true
}
func (c PathVerticalLine) PrintPathCommand() (string, string, bool) {
return "V", fmt.Sprintf("%v", float64(c)), false
}
func (c PathCubicBezier) PrintPathCommand() (string, string, bool) {
if len(c) == 0 {
return "", "", false
}
return "C", printTuples(c), true
}
func (c PathCubicBezier) Append(x1, y1, x2, y2, x, y float64) PathCubicBezier {
return append(c, PathCubicBezierParameterTuple{
X1: x1,
Y1: y1,
X2: x2,
Y2: y2,
X: x,
Y: y,
})
}
func (t PathCubicBezierParameterTuple) String() string {
return fmt.Sprintf("%v,%v %v,%v %v,%v", t.X1, t.Y1, t.X2, t.Y2, t.X, t.Y)
}
func (c PathSmoothCubicBezier) PrintPathCommand() (string, string, bool) {
if len(c) == 0 {
return "", "", false
}
return "S", printTuples(c), true
}
func (c PathSmoothCubicBezier) Append(x2, y2, x, y float64) PathSmoothCubicBezier {
return append(c, PathSmoothCubicBezierParameterTuple{
X2: x2,
Y2: y2,
X: x,
Y: y,
})
}
func (t PathSmoothCubicBezierParameterTuple) String() string {
return fmt.Sprintf("%v,%v %v,%v", t.X2, t.Y2, t.X, t.Y)
}
func (c PathQuadraticBezier) PrintPathCommand() (string, string, bool) {
if len(c) == 0 {
return "", "", false
}
return "Q", printTuples(c), true
}
func (c PathQuadraticBezier) Append(x1, y1, x, y float64) PathQuadraticBezier {
return append(c, PathQuadraticBezierParameterTuple{
X1: x1,
Y1: y1,
X: x,
Y: y,
})
}
func (t PathQuadraticBezierParameterTuple) String() string {
return fmt.Sprintf("%v,%v %v,%v", t.X1, t.Y1, t.X, t.Y)
}
func (c PathSmoothQuadraticBezier) PrintPathCommand() (string, string, bool) {
if len(c) == 0 {
return "", "", false
}
return "T", printTuples(c), true
}
func (c PathSmoothQuadraticBezier) Append(x, y float64) PathSmoothQuadraticBezier {
return append(c, PathSmoothQuadraticBezierParameterTuple{
X: x,
Y: y,
})
}
func (t PathSmoothQuadraticBezierParameterTuple) String() string {
return fmt.Sprintf("%v,%v", t.X, t.Y)
}
func (c PathElliptical) PrintPathCommand() (string, string, bool) {
if len(c) == 0 {
return "", "", false
}
return "A", printTuples(c), true
}
func (c PathElliptical) Append(rx, ry, angle float64, largeArc, clockwise bool, x, y float64) PathElliptical {
return append(c, PathEllipticalParameterTuple{
RX: rx,
RY: ry,
Angle: angle,
LargeArc: largeArc,
Clockwise: clockwise,
X: x,
Y: y,
})
}
func (t PathEllipticalParameterTuple) String() string {
var (
largeArcFlag int
sweepFlag int
)
if t.LargeArc {
largeArcFlag = 1
}
if t.Clockwise {
sweepFlag = 1
}
return fmt.Sprintf("%v,%v,%v,%d,%d,%v,%v", t.RX, t.RY, t.Angle, largeArcFlag, sweepFlag, t.X, t.Y)
}
func printTuples[S fmt.Stringer](vs []S) string {
ss := make([]string, len(vs))
for i, v := range vs {
ss[i] = fmt.Sprint(v)
}
return strings.Join(ss, " ")
}
+50
View File
@@ -0,0 +1,50 @@
package svg
import "fmt"
type (
DominantBaseline int
)
const (
DominantBaselineAuto DominantBaseline = iota
DominantBaselineTextBottom
DominantBaselineAlphabetic
DominantBaselineIdeographic
DominantBaselineMiddle
DominantBaselineCentral
DominantBaselineMathematical
DominantBaselineHanging
DominantBaselineTextTop
)
func (a DominantBaseline) PrintValue() (string, bool) {
return fmt.Sprintf("%q", a), true
}
func (a DominantBaseline) PrintKey() string {
return "dominant-baseline"
}
func (a DominantBaseline) String() string {
switch a {
case DominantBaselineTextBottom:
return "text-bottom"
case DominantBaselineAlphabetic:
return "alphabetic"
case DominantBaselineIdeographic:
return "ideographic"
case DominantBaselineMiddle:
return "middle"
case DominantBaselineCentral:
return "central"
case DominantBaselineMathematical:
return "mathematical"
case DominantBaselineHanging:
return "hanging"
case DominantBaselineTextTop:
return "text-top"
default:
return "auto"
}
}
+24
View File
@@ -0,0 +1,24 @@
package svg
type (
DX struct {
*LengthAttr[DXTag]
}
DXP struct {
PercentageAttr[DXTag]
}
DXTag struct{}
)
func (l *Length) AsDX() DX {
return DX{LengthAttr: (*LengthAttr[DXTag])(l)}
}
func (p Percentage) AsDX() DXP {
return DXP{PercentageAttr: (PercentageAttr[DXTag])(p)}
}
func (DXTag) PrintTag() string {
return "dx"
}
+24
View File
@@ -0,0 +1,24 @@
package svg
type (
DY struct {
*LengthAttr[DYTag]
}
DYP struct {
PercentageAttr[DYTag]
}
DYTag struct{}
)
func (l *Length) AsDY() DY {
return DY{LengthAttr: (*LengthAttr[DYTag])(l)}
}
func (p Percentage) AsDY() DYP {
return DYP{PercentageAttr: (PercentageAttr[DYTag])(p)}
}
func (DYTag) PrintTag() string {
return "dy"
}
+57
View File
@@ -0,0 +1,57 @@
package svg
import (
"fmt"
"io"
"strings"
)
type (
// Element is a convenience for implementing SVG element models.
// Element implements MarshalerReader.
Element[T Tag, A Attribute, C MarshalerReader] struct {
Attributes []A
Children []C
}
)
func (e *Element[T, A, C]) Attr(as ...A) *Element[T, A, C] {
e.Attributes = append(e.Attributes, as...)
return e
}
func (e *Element[T, A, C]) Child(cs ...C) *Element[T, A, C] {
e.Children = append(e.Children, cs...)
return e
}
func (e Element[T, A, C]) GetMarkup() string {
attrs := make([]string, len(e.Attributes))
for i, a := range e.Attributes {
attrs[i] = PrintAttribute(a)
}
children := make([]string, len(e.Children))
for i, c := range e.Children {
children[i] = c.GetMarkup()
}
tag := e.PrintTag()
return fmt.Sprintf(
`<%s %s>%s</%s>`,
tag,
strings.Join(attrs, " "),
strings.Join(children, ""),
tag,
)
}
func (e Element[T, A, C]) GetMarkupReader() io.Reader {
return newElementReader(e)
}
func (e Element[T, _, _]) PrintTag() string {
var t T
return t.PrintTag()
}
+110
View File
@@ -0,0 +1,110 @@
package svg
import (
"bytes"
"errors"
"io"
)
type (
elementReader[T Tag, A Attribute, C MarshalerReader] struct {
element Element[T, A, C]
buf *bytes.Buffer
openTagRead bool
numChildrenRead int
childrenRead bool
childReader io.Reader
closeTagRead bool
}
)
func newElementReader[T Tag, A Attribute, C MarshalerReader](e Element[T, A, C]) *elementReader[T, A, C] {
return &elementReader[T, A, C]{
element: e,
}
}
func (e *elementReader[T, A, C]) Read(p []byte) (totalRead int, err error) {
if e.buf == nil {
// just started reading.
// buffer the open tag
e.buf = new(bytes.Buffer)
// bytes.Buffer never returns an error on Write()
e.buf.WriteByte('<')
e.buf.WriteString(e.element.PrintTag())
for _, a := range e.element.Attributes {
e.buf.WriteByte(' ')
e.buf.WriteString(PrintAttribute(a))
}
e.buf.WriteByte('>')
}
if !e.openTagRead {
// read the open tag
n, err := e.buf.Read(p)
totalRead += n
if (err != nil && !errors.Is(err, io.EOF)) || n >= len(p) {
return totalRead, err
}
// done reading the open tag
e.openTagRead = true
e.buf.Reset()
p = p[n:]
}
// read the children
for ; e.numChildrenRead < len(e.element.Children); e.numChildrenRead += 1 {
if e.childReader == nil {
e.childReader = e.element.Children[e.numChildrenRead].GetMarkupReader()
}
// read the child
n, err := e.childReader.Read(p)
totalRead += n
if (err != nil && !errors.Is(err, io.EOF)) || n >= len(p) {
return totalRead, err
}
// done reading the child
e.childReader = nil
p = p[n:]
}
if !e.childrenRead {
// done reading the children
e.childrenRead = true
// buffer the close tag
e.buf.Write([]byte("</"))
e.buf.WriteString(e.element.PrintTag())
e.buf.WriteByte('>')
}
if !e.closeTagRead {
n, err := e.buf.Read(p)
totalRead += n
if (err != nil && !errors.Is(err, io.EOF)) || n >= len(p) {
return totalRead, err
}
// done writing the close tag
e.closeTagRead = true
return totalRead, err
}
return 0, io.EOF
}
+58
View File
@@ -0,0 +1,58 @@
package svg
type (
Ellipse = VoidElement[EllipseTag, EllipseAttribute]
EllipseTag struct{}
EllipseAttribute interface {
Attribute
IsEllipseAttribute()
}
)
func (EllipseTag) PrintTag() string {
return "ellipse"
}
func (RX) IsEllipseAttribute() {
}
func (RXP) IsEllipseAttribute() {
}
func (RY) IsEllipseAttribute() {
}
func (RYP) IsEllipseAttribute() {
}
func (CX) IsEllipseAttribute() {
}
func (CXP) IsEllipseAttribute() {
}
func (CY) IsEllipseAttribute() {
}
func (CYP) IsEllipseAttribute() {
}
func (PathLength) IsEllipseAttribute() {
}
func (Stroke) IsEllipseAttribute() {
}
func (StrokeWidth) IsEllipseAttribute() {
}
func (Fill) IsEllipseAttribute() {
}
func (Style) IsEllipseAttribute() {
}
func (VectorEffect) IsEllipseAttribute() {
}
+15
View File
@@ -0,0 +1,15 @@
package svg
import "fmt"
type (
Fill string
)
func (Fill) PrintKey() string {
return "fill"
}
func (f Fill) PrintValue() (string, bool) {
return fmt.Sprintf(`%q`, string(f)), true
}
+24
View File
@@ -0,0 +1,24 @@
package svg
type (
FontSize struct {
*LengthAttr[FontSizeTag]
}
FontSizeP struct {
PercentageAttr[FontSizeTag]
}
FontSizeTag struct{}
)
func (l *Length) AsFontSize() FontSize {
return FontSize{LengthAttr: (*LengthAttr[FontSizeTag])(l)}
}
func (p Percentage) AsFontSize() FontSizeP {
return FontSizeP{PercentageAttr: (PercentageAttr[FontSizeTag])(p)}
}
func (FontSizeTag) PrintTag() string {
return "font-size"
}
+37
View File
@@ -0,0 +1,37 @@
package svg
type (
G struct {
Element[GTag, GAttribute, GChildren]
}
GTag struct{}
GAttribute interface {
Attribute
IsGAttribute()
}
GChildren = MarshalerReader
)
func (GTag) PrintTag() string {
return "g"
}
func (Transform) IsGAttribute() {
}
func (X) IsGAttribute() {
}
func (XP) IsGAttribute() {
}
func (Y) IsGAttribute() {
}
func (YP) IsGAttribute() {
}
func (Class) IsGAttribute() {
}
+24
View File
@@ -0,0 +1,24 @@
package svg
type (
Height struct {
*LengthAttr[HeightTag]
}
HeightP struct {
PercentageAttr[HeightTag]
}
HeightTag struct{}
)
func (l *Length) AsHeight() Height {
return Height{LengthAttr: (*LengthAttr[HeightTag])(l)}
}
func (p Percentage) AsHeightP() HeightP {
return HeightP{PercentageAttr: (PercentageAttr[HeightTag])(p)}
}
func (HeightTag) PrintTag() string {
return "height"
}
+241
View File
@@ -0,0 +1,241 @@
package svg
import "fmt"
type (
Length struct {
number float64
absUnit AbsoluteLengthUnit
relUnit RelativeLengthUnit
}
LengthAttr[T Tag] Length
)
func NewLength(n float64) *Length {
return &Length{
number: n,
}
}
func (l *Length) Number(n float64) *Length {
l.number = n
return l
}
func (l *Length) Unit(u AbsoluteLengthUnit) *Length {
l.absUnit = u
l.relUnit = 0
return l
}
func (l *Length) RUnit(u RelativeLengthUnit) *Length {
l.relUnit = u
l.absUnit = 0
return l
}
func (l Length) String() string {
if l.absUnit != 0 {
return fmt.Sprintf("%v%s", l.number, l.absUnit)
}
if l.relUnit != 0 {
return fmt.Sprintf("%v%s", l.number, l.relUnit)
}
return fmt.Sprintf("%v", l.number)
}
func (LengthAttr[T]) PrintKey() string {
var tag T
return tag.PrintTag()
}
func (a LengthAttr[T]) PrintValue() (string, bool) {
return fmt.Sprintf(`"%s"`, Length(a)), true
}
// Relative length units
type RelativeLengthUnit int
const (
_ RelativeLengthUnit = iota
// based on font
Cap
Ch
Em
Ex
Ic
Lh
// based on root element's font
Rcap
Rch
Rem
Rex
Ric
Rlh
// based on viewport
Vh
Vw
Vmax
Vmin
Vb
Vi
// small viewport
Svh
Svw
Svmax
Svmin
Svb
Svi
// large viewport
Lvh
Lvw
Lvmax
Lvmin
Lvb
Lvi
// dynamic viewport
Dvh
Dvw
Dvmax
Dvmin
Dvb
Dvi
// container query
Cqw
Cqh
Cqi
Cqb
Cqmin
Cqmax
)
func (u RelativeLengthUnit) String() string {
switch u {
case Cap:
return "cap"
case Ch:
return "ch"
case Em:
return "em"
case Ex:
return "ex"
case Ic:
return "ic"
case Lh:
return "lh"
case Rcap:
return "rcap"
case Rch:
return "rch"
case Rem:
return "rem"
case Rex:
return "rex"
case Ric:
return "ric"
case Rlh:
return "rlh"
case Vh:
return "vh"
case Vw:
return "vw"
case Vmax:
return "vmax"
case Vmin:
return "vmin"
case Vb:
return "vb"
case Vi:
return "vi"
case Svh:
return "svh"
case Svw:
return "svw"
case Svmax:
return "svmax"
case Svmin:
return "svmin"
case Svb:
return "svb"
case Svi:
return "svi"
case Lvh:
return "lvh"
case Lvw:
return "lvw"
case Lvmax:
return "lvmax"
case Lvmin:
return "lvmin"
case Lvb:
return "lvb"
case Lvi:
return "lvi"
case Dvh:
return "dvh"
case Dvw:
return "dvw"
case Dvmax:
return "dvmax"
case Dvmin:
return "dvmin"
case Dvb:
return "dvb"
case Dvi:
return "dvi"
case Cqw:
return "cqw"
case Cqh:
return "cqh"
case Cqi:
return "cqi"
case Cqb:
return "cqb"
case Cqmin:
return "cqmin"
case Cqmax:
return "cqmax"
default:
return ""
}
}
// Absolute length units
type AbsoluteLengthUnit int
const (
_ AbsoluteLengthUnit = iota
Px
Cm
Mm
Q
In
Pc
Pt
)
func (u AbsoluteLengthUnit) String() string {
switch u {
case Px:
return "px"
case Cm:
return "cm"
case Mm:
return "mm"
case Q:
return "q"
case In:
return "in"
case Pc:
return "pc"
case Pt:
return "pt"
default:
return ""
}
}
+23
View File
@@ -0,0 +1,23 @@
package svg
type (
LengthAdjust int
)
const (
LengthAdjustSpacing LengthAdjust = iota
LengthAdjustSpacingAndGlyph
)
func (LengthAdjust) PrintTag() string {
return "lengthAdjust"
}
func (l LengthAdjust) PrintValue() (string, bool) {
switch l {
case LengthAdjustSpacingAndGlyph:
return "spacingAndGlyphs", true
default:
return "spacing", true
}
}
+31
View File
@@ -0,0 +1,31 @@
package svg
type (
Line = VoidElement[LineTag, LineAttribute]
LineTag struct{}
LineAttribute interface {
Attribute
IsLineAttribute()
}
)
func (LineTag) PrintTag() string {
return "line"
}
func (X1) IsLineAttribute() {
}
func (X2) IsLineAttribute() {
}
func (Y1) IsLineAttribute() {
}
func (Y2) IsLineAttribute() {
}
func (PathLength) IsLineAttribute() {
}
+19
View File
@@ -0,0 +1,19 @@
package svg
import "io"
type (
// All SVG element models must satisfy the Marshaler interface.
Marshaler interface {
GetMarkup() string
}
Reader interface {
GetMarkupReader() io.Reader
}
MarshalerReader interface {
Marshaler
Reader
}
)
+22
View File
@@ -0,0 +1,22 @@
package svg
type (
Path = VoidElement[PathTag, PathAttribute]
PathTag struct{}
PathAttribute interface {
Attribute
IsPathAttribute()
}
)
func (PathTag) PrintTag() string {
return "path"
}
func (D) IsPathAttribute() {
}
func (PathLength) IsPathAttribute() {
}
+15
View File
@@ -0,0 +1,15 @@
package svg
import "fmt"
type (
PathLength float64
)
func (PathLength) PrintKey() string {
return "pathLength"
}
func (l PathLength) PrintValue() (string, bool) {
return fmt.Sprintf(`"%v"`, l), true
}
+22
View File
@@ -0,0 +1,22 @@
package svg
import "fmt"
type (
Percentage float64
PercentageAttr[T Tag] Percentage
)
func (p Percentage) String() string {
return fmt.Sprintf("%v%%", float64(p))
}
func (PercentageAttr[T]) PrintKey() string {
var t T
return t.PrintTag()
}
func (a PercentageAttr[T]) PrintValue() (string, bool) {
return fmt.Sprintf("%q", Percentage(a)), true
}
+26
View File
@@ -0,0 +1,26 @@
package svg
import (
"fmt"
"strings"
)
type (
Points []Point
Point struct {
X float64
Y float64
}
)
func (Points) PrintKey() string {
return "points"
}
func (ps Points) PrintValue() (string, bool) {
ss := make([]string, len(ps))
for i, p := range ps {
ss[i] = fmt.Sprintf("%v,%v", p.X, p.Y)
}
return fmt.Sprintf("%q", strings.Join(ss, " ")), true
}
+22
View File
@@ -0,0 +1,22 @@
package svg
type (
Polygon = VoidElement[PolygonTag, PolygonAttribute]
PolygonTag struct{}
PolygonAttribute interface {
Attribute
IsPolygonAttribute()
}
)
func (PolygonTag) PrintTag() string {
return "path"
}
func (Points) IsPolygonAttribute() {
}
func (PathLength) IsPolygonAttribute() {
}
+37
View File
@@ -0,0 +1,37 @@
package svg
type (
Polyline = VoidElement[PolylineTag, PolylineAttribute]
PolylineTag struct{}
PolylineAttribute interface {
Attribute
IsPolylineAttribute()
}
)
func (PolylineTag) PrintTag() string {
return "polyline"
}
func (PathLength) IsPolylineAttribute() {
}
func (Points) IsPolylineAttribute() {
}
func (Stroke) IsPolylineAttribute() {
}
func (Fill) IsPolylineAttribute() {
}
func (StrokeWidth) IsPolylineAttribute() {
}
func (StrokeWidthP) IsPolylineAttribute() {
}
func (VectorEffect) IsPolylineAttribute() {
}
+70
View File
@@ -0,0 +1,70 @@
package svg
import "fmt"
// TODO: consider reworking this WHOLE API/PACKAGE to make it more of a chaining API.
type (
PreserveAspectRatio struct {
Align *AlignValue
MeetOrSlice MeetOrSliceValue
}
AlignValue struct {
X AlignValueComponent
Y AlignValueComponent
}
AlignValueComponent int
MeetOrSliceValue int
)
const (
AlignMid AlignValueComponent = iota
AlignMin
AlignMax
_ MeetOrSliceValue = iota
Meet
Slice
)
func (r PreserveAspectRatio) PrintKey() string {
return "preserveAspectRatio"
}
func (r PreserveAspectRatio) PrintValue() (string, bool) {
if mos := r.MeetOrSlice.String(); mos != "" {
return fmt.Sprintf(`"%s %s"`, r.Align, mos), true
}
return fmt.Sprintf("%q", r.Align), true
}
func (v *AlignValue) String() string {
if v == nil {
return "none"
}
return fmt.Sprintf("x%sY%s", v.X, v.Y)
}
func (v AlignValueComponent) String() string {
switch v {
case AlignMin:
return "Min"
case AlignMax:
return "Max"
default:
return "Mid"
}
}
func (v MeetOrSliceValue) String() string {
switch v {
case Meet:
return "meet"
case Slice:
return "slice"
default:
return ""
}
}
+24
View File
@@ -0,0 +1,24 @@
package svg
type (
R struct {
*LengthAttr[RTag]
}
RP struct {
PercentageAttr[RTag]
}
RTag struct{}
)
func (l *Length) AsR() R {
return R{LengthAttr: (*LengthAttr[RTag])(l)}
}
func (p Percentage) AsR() RP {
return RP{PercentageAttr: (PercentageAttr[RTag])(p)}
}
func (RTag) PrintTag() string {
return "r"
}
+71
View File
@@ -0,0 +1,71 @@
package svg
type (
// Rect models a <rect> element
Rect = VoidElement[RectTag, RectAttribute]
RectTag struct{}
// SVGAttributes are Attributes allowed on SVGs
RectAttribute interface {
Attribute
IsRectAttribute()
}
)
func (RectTag) PrintTag() string {
return "rect"
}
func (X) IsRectAttribute() {
}
func (XP) IsRectAttribute() {
}
func (Y) IsRectAttribute() {
}
func (YP) IsRectAttribute() {
}
func (Width) IsRectAttribute() {
}
func (WidthP) IsRectAttribute() {
}
func (Height) IsRectAttribute() {
}
func (HeightP) IsRectAttribute() {
}
func (RX) IsRectAttribute() {
}
func (RXP) IsRectAttribute() {
}
func (RY) IsRectAttribute() {
}
func (RYP) IsRectAttribute() {
}
func (PathLength) IsRectAttribute() {
}
func (Class) IsRectAttribute() {
}
func (Fill) IsRectAttribute() {
}
func (Stroke) IsRectAttribute() {
}
func (StrokeWidth) IsRectAttribute() {
}
func (Style) IsRectAttribute() {
}
+33
View File
@@ -0,0 +1,33 @@
package svg
import "fmt"
type (
Rotate float64
RotateAuto struct{}
RotateAutoReverse struct{}
)
func (r Rotate) PrintKey() string {
return "rotate"
}
func (r Rotate) PrintValue() (string, bool) {
return fmt.Sprintf(`"%v"`, float64(r)), true
}
func (r RotateAuto) PrintKey() string {
return "rotate"
}
func (r RotateAuto) PrintValue() (string, bool) {
return `"auto"`, true
}
func (r RotateAutoReverse) PrintKey() string {
return "rotate"
}
func (r RotateAutoReverse) PrintValue() (string, bool) {
return `"auto-reverse"`, true
}
+24
View File
@@ -0,0 +1,24 @@
package svg
type (
RX struct {
*LengthAttr[RXTag]
}
RXP struct {
PercentageAttr[RXTag]
}
RXTag struct{}
)
func (l *Length) AsRX() RX {
return RX{LengthAttr: (*LengthAttr[RXTag])(l)}
}
func (p Percentage) AsRX() RXP {
return RXP{PercentageAttr: (PercentageAttr[RXTag])(p)}
}
func (RXTag) PrintTag() string {
return "rx"
}
+24
View File
@@ -0,0 +1,24 @@
package svg
type (
RY struct {
*LengthAttr[RYTag]
}
RYP struct {
PercentageAttr[RYTag]
}
RYTag struct{}
)
func (l *Length) AsRY() RY {
return RY{LengthAttr: (*LengthAttr[RYTag])(l)}
}
func (p Percentage) AsRY() RYP {
return RYP{PercentageAttr: (PercentageAttr[RYTag])(p)}
}
func (RYTag) PrintTag() string {
return "ry"
}
+15
View File
@@ -0,0 +1,15 @@
package svg
import "fmt"
type (
Stroke string
)
func (Stroke) PrintKey() string {
return "stroke"
}
func (s Stroke) PrintValue() (string, bool) {
return fmt.Sprintf(`%q`, string(s)), true
}
+24
View File
@@ -0,0 +1,24 @@
package svg
type (
StrokeWidth struct {
*LengthAttr[StrokeWidthTag]
}
StrokeWidthP struct {
PercentageAttr[StrokeWidthTag]
}
StrokeWidthTag struct{}
)
func (l *Length) AsStrokeWidth() StrokeWidth {
return StrokeWidth{LengthAttr: (*LengthAttr[StrokeWidthTag])(l)}
}
func (p Percentage) AsStrokeWidth() StrokeWidthP {
return StrokeWidthP{PercentageAttr: (PercentageAttr[StrokeWidthTag])(p)}
}
func (StrokeWidthTag) PrintTag() string {
return "stroke-width"
}
+29
View File
@@ -0,0 +1,29 @@
package svg
import (
"fmt"
"html"
"maps"
"slices"
"strings"
)
type (
Style map[string]string
)
func (m Style) PrintKey() string {
return "style"
}
func (m Style) PrintValue() (string, bool) {
keys := slices.Collect(maps.Keys(m))
slices.Sort(keys)
parts := make([]string, len(m))
for i, k := range keys {
parts[i] = fmt.Sprintf(`%s: %s`, k, html.EscapeString(m[k]))
}
return fmt.Sprintf("%q", strings.Join(parts, "; ")), true
}
+99
View File
@@ -0,0 +1,99 @@
package svg
type (
// SVG models an <svg> element
SVG struct {
Element[SVGTag, SVGAttribute, SVGChildren]
}
SVGTag struct{}
// SVGAttributes are Attributes allowed on SVGs
SVGAttribute interface {
Attribute
IsSVGAttribute()
}
SVGChildren = MarshalerReader
)
// NewSVG create a SVG with the common version 1.1 and svg namespace atributes
func NewSVG() *SVG {
return &SVG{
Element: Element[SVGTag, SVGAttribute, MarshalerReader]{
Attributes: []SVGAttribute{
SVGVersion{
Major: 1,
Minor: 1,
},
XMLNSW32000SVG,
},
},
}
}
func (t SVGTag) PrintTag() string {
return "svg"
}
func (X) IsSVGAttribute() {
}
func (XP) IsSVGAttribute() {
}
func (Y) IsSVGAttribute() {
}
func (YP) IsSVGAttribute() {
}
func (Width) IsSVGAttribute() {
}
func (WidthP) IsSVGAttribute() {
}
func (Height) IsSVGAttribute() {
}
func (HeightP) IsSVGAttribute() {
}
func (ViewBox) IsSVGAttribute() {
}
func (Style) IsSVGAttribute() {
}
func (Class) IsSVGAttribute() {
}
func (PreserveAspectRatio) IsSVGAttribute() {
}
func (SVGVersion) IsSVGAttribute() {
}
func (XMLNS) IsSVGAttribute() {
}
func (Transform) IsSVGAttribute() {
}
func (TransformRotate) IsSVGAttribute() {
}
func (TransformRotateAbout) IsSVGAttribute() {
}
func (TransformTranslate) IsSVGAttribute() {
}
func (TransformSkewX) IsSVGAttribute() {
}
func (TransformSkewY) IsSVGAttribute() {
}
func (TransformScale) IsSVGAttribute() {
}
+37
View File
@@ -0,0 +1,37 @@
package svg
import (
"fmt"
"io"
)
func ExampleSVG() {
s := NewSVG().
Attr(
NewLength(10).
AsX(),
NewLength(20).
AsY(),
Style{
"font-size": "8px",
},
).
Child(
Rect{
Attributes: []RectAttribute{
NewLength(30).
AsX(),
Percentage(40).
AsY(),
},
},
NewText("abc 123"),
)
b, err := io.ReadAll(s.GetMarkupReader())
if err != nil {
panic(err)
}
fmt.Println(string(b))
// Output: <svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="10" y="20" style="font-size: 8px"><rect x="30" y="40%"/><text>abc 123</text></svg>
}
+7
View File
@@ -0,0 +1,7 @@
package svg
type (
Tag interface {
PrintTag() string
}
)
+130
View File
@@ -0,0 +1,130 @@
package svg
import (
"bytes"
"html"
"io"
)
type (
// Text models an <text> element
Text = Element[TextTag, TextAttribute, TextChild]
TextTag struct{}
TextAttribute interface {
Attribute
IsTextAttribute()
}
TextChild interface {
MarshalerReader
IsTextChild()
}
RawText string
)
func NewText(t string) *Text {
return &Text{
Children: []TextChild{
RawText(t),
},
}
}
func (t TextTag) PrintTag() string {
return "text"
}
func (t RawText) GetMarkup() string {
return html.EscapeString(string(t))
}
func (t RawText) GetMarkupReader() io.Reader {
return bytes.NewReader([]byte(t.GetMarkup()))
}
func (t RawText) IsTextChild() {
}
func (X) IsTextAttribute() {
}
func (XP) IsTextAttribute() {
}
func (Y) IsTextAttribute() {
}
func (YP) IsTextAttribute() {
}
func (DX) IsTextAttribute() {
}
func (DXP) IsTextAttribute() {
}
func (DY) IsTextAttribute() {
}
func (DYP) IsTextAttribute() {
}
func (Rotate) IsTextAttribute() {
}
func (RotateAuto) IsTextAttribute() {
}
func (RotateAutoReverse) IsTextAttribute() {
}
func (Fill) IsTextAttribute() {
}
func (TextLength) IsTextAttribute() {
}
func (TextLengthP) IsTextAttribute() {
}
func (LengthAdjust) IsTextAttribute() {
}
func (TextAnchor) IsTextAttribute() {
}
func (FontSize) IsTextAttribute() {
}
func (FontSizeP) IsTextAttribute() {
}
func (Transform) IsTextAttribute() {
}
func (TransformRotate) IsTextAttribute() {
}
func (TransformRotateAbout) IsTextAttribute() {
}
func (TransformTranslate) IsTextAttribute() {
}
func (TransformSkewX) IsTextAttribute() {
}
func (TransformSkewY) IsTextAttribute() {
}
func (TransformScale) IsTextAttribute() {
}
func (DominantBaseline) IsTextAttribute() {
}
func (Style) IsTextAttribute() {
}
func (Class) IsTextAttribute() {
}
+26
View File
@@ -0,0 +1,26 @@
package svg
type (
TextAnchor int
)
const (
TextAnchorStart TextAnchor = iota
TextAnchorMiddle
TextAnchorEnd
)
func (TextAnchor) PrintKey() string {
return "text-anchor"
}
func (t TextAnchor) PrintValue() (string, bool) {
switch t {
case TextAnchorMiddle:
return `"middle"`, true
case TextAnchorEnd:
return `"end"`, true
default:
return `"start"`, true
}
}
+24
View File
@@ -0,0 +1,24 @@
package svg
type (
TextLength struct {
*LengthAttr[TextLengthTag]
}
TextLengthP struct {
PercentageAttr[TextLengthTag]
}
TextLengthTag struct{}
)
func (l *Length) AsTextLength() TextLength {
return TextLength{LengthAttr: (*LengthAttr[TextLengthTag])(l)}
}
func (p Percentage) AsTextLength() TextLengthP {
return TextLengthP{PercentageAttr: (PercentageAttr[TextLengthTag])(p)}
}
func (TextLengthTag) PrintTag() string {
return "textLength"
}
+155
View File
@@ -0,0 +1,155 @@
package svg
import (
"fmt"
"strings"
)
// TODO consider replacing the Transform matrices with a single 'matrix' type with different constructors and a builder/chaining api.
type (
Transform []TransformExpression
// Transform is itself a Transform, meaning you could nest them, if you wanted.
TransformExpression interface {
PrintTransform() string
}
// TransformExpression implementations
// They can tehemselves be used as standalone 'transform' attributes.
TransformRotate float64
TransformRotateAbout struct {
A float64
X, Y float64
}
TransformTranslate struct {
X, Y float64
}
TransformSkewX float64
TransformSkewY float64
TransformScale struct {
X, Y float64
}
)
func (Transform) PrintKey() string {
return "transform"
}
func (ts Transform) PrintValue() (string, bool) {
return fmt.Sprintf("%q", ts.PrintTransform()), true
}
func (ts Transform) PrintTransform() string {
ss := make([]string, len(ts))
for i, t := range ts {
ss[i] = t.PrintTransform()
}
return strings.Join(ss, " ")
}
func (ts Transform) Rotate(a float64) Transform {
return append(ts, TransformRotate(a))
}
func (ts Transform) RotateAbout(a, x, y float64) Transform {
return append(ts, TransformRotateAbout{A: a, X: x, Y: y})
}
func (ts Transform) Translate(x, y float64) Transform {
return append(ts, TransformTranslate{X: x, Y: y})
}
func (ts Transform) SkewX(x float64) Transform {
return append(ts, TransformSkewX(x))
}
func (ts Transform) SkewY(y float64) Transform {
return append(ts, TransformSkewY(y))
}
func (ts Transform) Scale(x, y float64) Transform {
return append(ts, TransformScale{X: x, Y: y})
}
func (TransformRotate) PrintKey() string {
return "transform"
}
func (t TransformRotate) PrintValue() (string, bool) {
return fmt.Sprintf("%q", t.PrintTransform()), true
}
func (t TransformRotate) PrintTransform() string {
return fmt.Sprintf("rotate(%v)", float64(t))
}
func (t TransformRotate) About(x, y float64) TransformRotateAbout {
return TransformRotateAbout{
A: float64(t),
X: x,
Y: y,
}
}
func (TransformRotateAbout) PrintKey() string {
return "transform"
}
func (t TransformRotateAbout) PrintValue() (string, bool) {
return fmt.Sprintf("%q", t.PrintTransform()), true
}
func (t TransformRotateAbout) PrintTransform() string {
return fmt.Sprintf("rotate(%v %v %v)", t.A, t.X, t.Y)
}
func (TransformTranslate) PrintKey() string {
return "transform"
}
func (t TransformTranslate) PrintValue() (string, bool) {
return fmt.Sprintf("%q", t.PrintTransform()), true
}
func (t TransformTranslate) PrintTransform() string {
return fmt.Sprintf("translate(%v %v)", t.X, t.Y)
}
func (TransformSkewX) PrintKey() string {
return "transform"
}
func (t TransformSkewX) PrintValue() (string, bool) {
return fmt.Sprintf("%q", t.PrintTransform()), true
}
func (t TransformSkewX) PrintTransform() string {
return fmt.Sprintf("skewX(%v)", float64(t))
}
func (TransformSkewY) PrintKey() string {
return "transform"
}
func (t TransformSkewY) PrintValue() (string, bool) {
return fmt.Sprintf("%q", t.PrintTransform()), true
}
func (t TransformSkewY) PrintTransform() string {
return fmt.Sprintf("skewY(%v)", float64(t))
}
func (TransformScale) PrintKey() string {
return "transform"
}
func (t TransformScale) PrintValue() (string, bool) {
return fmt.Sprintf("%q", t.PrintTransform()), true
}
func (t TransformScale) PrintTransform() string {
return fmt.Sprintf("scale(%v %v)", t.X, t.Y)
}
+35
View File
@@ -0,0 +1,35 @@
package svg
type (
VectorEffect int
)
const (
VectorEffectNone VectorEffect = iota
VectorEffectNonScalingStroke
// DO NOT USE - no browser support yet!
VectorEffectNonScalingSize
// DO NOT USE - no browser support yet!
VectorEffectNonRotation
// DO NOT USE - no browser support yet!
VectorEffectFixedPosition
)
func (VectorEffect) PrintKey() string {
return "vector-effect"
}
func (a VectorEffect) PrintValue() (string, bool) {
switch a {
case VectorEffectNonScalingStroke:
return `"non-scaling-stroke"`, true
case VectorEffectNonScalingSize:
return `"non-scaling-size"`, true
case VectorEffectNonRotation:
return `"non-rotation"`, true
case VectorEffectFixedPosition:
return `"fixed-position"`, true
default:
return `"none"`, true
}
}
+18
View File
@@ -0,0 +1,18 @@
package svg
import "fmt"
type (
SVGVersion struct {
Major int
Minor int
}
)
func (v SVGVersion) PrintKey() string {
return "version"
}
func (v SVGVersion) PrintValue() (string, bool) {
return fmt.Sprintf(`"%d.%d"`, v.Major, v.Minor), true
}
+20
View File
@@ -0,0 +1,20 @@
package svg
import (
"fmt"
)
type (
ViewBox struct {
X, Y float64
Width, Height float64
}
)
func (b ViewBox) PrintKey() string {
return "viewBox"
}
func (b ViewBox) PrintValue() (string, bool) {
return fmt.Sprintf(`"%v %v %v %v"`, b.X, b.Y, b.Width, b.Height), true
}
+42
View File
@@ -0,0 +1,42 @@
package svg
import (
"bytes"
"fmt"
"io"
"strings"
)
type (
// VoidElement is like Element but for void elements
VoidElement[T Tag, A Attribute] struct {
Attributes []A
}
)
func (e *VoidElement[T, A]) Attr(as ...A) *VoidElement[T, A] {
e.Attributes = append(e.Attributes, as...)
return e
}
func (e VoidElement[T, A]) GetMarkup() string {
attrs := make([]string, len(e.Attributes))
for i, a := range e.Attributes {
attrs[i] = PrintAttribute(a)
}
return fmt.Sprintf(
`<%s %s/>`,
e.PrintTag(),
strings.Join(attrs, " "),
)
}
func (e VoidElement[T, A]) GetMarkupReader() io.Reader {
return bytes.NewReader([]byte(e.GetMarkup()))
}
func (e VoidElement[T, _]) PrintTag() string {
var t T
return t.PrintTag()
}
+24
View File
@@ -0,0 +1,24 @@
package svg
type (
Width struct {
*LengthAttr[WidthTag]
}
WidthP struct {
PercentageAttr[WidthTag]
}
WidthTag struct{}
)
func (l *Length) AsWidth() Width {
return Width{LengthAttr: (*LengthAttr[WidthTag])(l)}
}
func (p Percentage) AsWidth() WidthP {
return WidthP{PercentageAttr: (PercentageAttr[WidthTag])(p)}
}
func (WidthTag) PrintTag() string {
return "width"
}
+24
View File
@@ -0,0 +1,24 @@
package svg
type (
X struct {
*LengthAttr[XTag]
}
XP struct {
PercentageAttr[XTag]
}
XTag struct{}
)
func (l *Length) AsX() X {
return X{LengthAttr: (*LengthAttr[XTag])(l)}
}
func (p Percentage) AsX() XP {
return XP{PercentageAttr: (PercentageAttr[XTag])(p)}
}
func (XTag) PrintTag() string {
return "x"
}
+24
View File
@@ -0,0 +1,24 @@
package svg
type (
X1 struct {
*LengthAttr[X1Tag]
}
X1P struct {
PercentageAttr[X1Tag]
}
X1Tag struct{}
)
func (l *Length) AsX1() X1 {
return X1{LengthAttr: (*LengthAttr[X1Tag])(l)}
}
func (p Percentage) AsX1() X1P {
return X1P{PercentageAttr: (PercentageAttr[X1Tag])(p)}
}
func (X1Tag) PrintTag() string {
return "x1"
}
+24
View File
@@ -0,0 +1,24 @@
package svg
type (
X2 struct {
*LengthAttr[X2Tag]
}
X2P struct {
PercentageAttr[X2Tag]
}
X2Tag struct{}
)
func (l *Length) AsX2() X2 {
return X2{LengthAttr: (*LengthAttr[X2Tag])(l)}
}
func (p Percentage) AsX2() X2P {
return X2P{PercentageAttr: (PercentageAttr[X2Tag])(p)}
}
func (X2Tag) PrintTag() string {
return "x2"
}
+19
View File
@@ -0,0 +1,19 @@
package svg
import "fmt"
const (
XMLNSW32000SVG XMLNS = "http://www.w3.org/2000/svg"
)
type (
XMLNS string
)
func (v XMLNS) PrintKey() string {
return "xmlns"
}
func (v XMLNS) PrintValue() (string, bool) {
return fmt.Sprintf("%q", string(v)), true
}
+24
View File
@@ -0,0 +1,24 @@
package svg
type (
Y struct {
*LengthAttr[YTag]
}
YP struct {
PercentageAttr[YTag]
}
YTag struct{}
)
func (l *Length) AsY() Y {
return Y{LengthAttr: (*LengthAttr[YTag])(l)}
}
func (p Percentage) AsY() YP {
return YP{PercentageAttr: (PercentageAttr[YTag])(p)}
}
func (YTag) PrintTag() string {
return "y"
}

Some files were not shown because too many files have changed in this diff Show More