@necrolab/dashboard 0.5.21 → 0.5.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@necrolab/dashboard",
3
- "version": "0.5.21",
3
+ "version": "0.5.23",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "rm -rf dist && vite build && npx workbox-cli generateSW workbox-config.cjs",
@@ -8,13 +8,11 @@
8
8
  "bot": "node dev-server.js",
9
9
  "expose": "node dev-server.js",
10
10
  "postinstall": "node postinstall.js",
11
- "updaterenderer": "npm i @necrolab/tm-renderer@latest",
12
11
  "preview": "vite preview",
13
12
  "lint": "npx eslint src/ --fix"
14
13
  },
15
14
  "dependencies": {
16
15
  "@msgpack/msgpack": "^3.0.0-beta2",
17
- "@necrolab/tm-renderer": "^0.1.12",
18
16
  "@vitejs/plugin-vue": "^5.2.1",
19
17
  "@vueuse/core": "^11.3.0",
20
18
  "autoprefixer": "^10.4.21",
@@ -10,6 +10,10 @@
10
10
  <div class="grid grid-cols-12 items-center py-3 px-2 sm:px-4 gap-2 sm:gap-3">
11
11
  <div class="col-span-9 sm:col-span-10">
12
12
  <div class="flex-gap-2 items-center sm:gap-3 cursor-pointer flex-1" @click="handleFilterClick(filter.id)">
13
+ <div
14
+ class="filter-color-dot flex-shrink-0"
15
+ :style="{ backgroundColor: color }">
16
+ </div>
13
17
  <div class="filter-type-badge flex-shrink-0">
14
18
  <component :is="getFilterIcon()" class="w-3 h-3 sm:w-4 sm:h-4" />
15
19
  </div>
@@ -169,6 +173,10 @@ const props = defineProps({
169
173
  filterBuilder: {
170
174
  type: Object,
171
175
  required: true
176
+ },
177
+ color: {
178
+ type: String,
179
+ default: "#7bc999"
172
180
  }
173
181
  });
174
182
 
@@ -292,15 +300,7 @@ props.filterBuilder.onUpdate(() => {
292
300
 
293
301
  <style scoped>
294
302
  .filter-card {
295
- @apply bg-dark-500 border border-dark-625/30 relative mb-2 transition-all duration-200;
296
- }
297
-
298
- .filter-card:hover {
299
- transform: scale(1.03);
300
- }
301
-
302
- .filter-card:active {
303
- transform: scale(0.98);
303
+ @apply bg-dark-500 border border-dark-625/30 relative mb-2 transition-colors duration-200;
304
304
  }
305
305
 
306
306
  .filter-card:hover:not(.expanded-filter) {
@@ -315,6 +315,11 @@ props.filterBuilder.onUpdate(() => {
315
315
  @apply border-t border-t-dark-625/20;
316
316
  }
317
317
 
318
+ .filter-color-dot {
319
+ @apply w-2.5 h-2.5 rounded-full flex-shrink-0;
320
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
321
+ }
322
+
318
323
  .filter-type-badge {
319
324
  @apply w-6 h-6 sm:w-8 sm:h-8 rounded-full bg-dark-400 flex items-center justify-center flex-shrink-0;
320
325
  }
@@ -353,17 +358,29 @@ props.filterBuilder.onUpdate(() => {
353
358
 
354
359
  .drag-btn {
355
360
  @apply text-light-500;
361
+ transition: all 0.2s ease;
356
362
 
357
363
  &:hover {
358
364
  @apply bg-dark-300 border-dark-550 text-yellow-400 shadow-md;
365
+ transform: scale(1.1);
366
+ }
367
+
368
+ &:active {
369
+ transform: scale(0.95);
359
370
  }
360
371
  }
361
372
 
362
373
  .delete-btn {
363
374
  @apply bg-error-500/30 border-error-400/50 text-error-300;
375
+ transition: all 0.2s ease;
364
376
 
365
377
  &:hover {
366
378
  @apply bg-error-400/80 border-error-500 text-white shadow-md;
379
+ transform: scale(1.1);
380
+ }
381
+
382
+ &:active {
383
+ transform: scale(0.95);
367
384
  }
368
385
  }
369
386
 
@@ -3,14 +3,55 @@ const log = (...args) => DEBUG && console.log("[filter]", ...args);
3
3
 
4
4
  const colors = {
5
5
  HIGHLIGHT: "#d3f8e2",
6
- SELECTED: "#a9def9",
7
- SELECTED_EXPANDED: "#6bb6ff",
6
+ SELECTED: "#7bc999",
7
+ SELECTED_EXPANDED: "#a0ddb5",
8
8
  EXCLUDED: "#f694c1",
9
9
  EXCLUDED_EXPANDED: "#f472b6",
10
10
  UNSELECTABLE: "#311432"
11
11
  };
12
12
 
13
- const BASE_CSS = `.svg-wrapper path[generaladmission]:hover {fill: ${colors.HIGHLIGHT};pointer-events:all;cursor:pointer;}`;
13
+ // Color palette for unique filter colors - distinct but professional
14
+ const filterColorPalette = [
15
+ "#7bc999", // Mint green
16
+ "#6ba8d6", // Steel blue
17
+ "#d4a876", // Tan/gold
18
+ "#a88bc9", // Soft purple
19
+ "#e89ba3", // Coral pink
20
+ "#7dc9c9", // Turquoise
21
+ "#c9a87b", // Warm tan
22
+ "#8bc99a", // Light green
23
+ "#9ba8c9", // Periwinkle
24
+ "#c9b88b" // Khaki
25
+ ];
26
+
27
+ const getFilterColor = (filterIndex, isExpanded, isExcluded) => {
28
+ if (isExcluded) {
29
+ return isExpanded ? colors.EXCLUDED_EXPANDED : colors.EXCLUDED;
30
+ }
31
+
32
+ // Use palette colors for normal filters
33
+ const baseColor = filterColorPalette[filterIndex % filterColorPalette.length];
34
+
35
+ if (!isExpanded) {
36
+ return baseColor;
37
+ }
38
+
39
+ // Make expanded filters brighter
40
+ const hex = baseColor.replace('#', '');
41
+ const r = parseInt(hex.substr(0, 2), 16);
42
+ const g = parseInt(hex.substr(2, 2), 16);
43
+ const b = parseInt(hex.substr(4, 2), 16);
44
+
45
+ // Lighten by 20%
46
+ const lighter = (c) => Math.min(255, Math.floor(c + (255 - c) * 0.3));
47
+ return `#${lighter(r).toString(16).padStart(2, '0')}${lighter(g).toString(16).padStart(2, '0')}${lighter(b).toString(16).padStart(2, '0')}`;
48
+ };
49
+
50
+ const BASE_CSS = `
51
+ .svg-wrapper path[generaladmission]:hover {fill: ${colors.HIGHLIGHT};pointer-events:all;cursor:pointer;}
52
+ .svg-wrapper path {pointer-events: all; cursor: pointer; touch-action: manipulation;}
53
+ .svg-wrapper {touch-action: manipulation; -webkit-user-select: none; user-select: none;}
54
+ `;
14
55
 
15
56
  const getAttributeValue = (element, attributeName) => {
16
57
  try {
@@ -211,9 +252,12 @@ export default class FilterBuilder {
211
252
  }
212
253
 
213
254
  addRowHandlers() {
255
+ let lastHighlightedSection = null;
256
+
214
257
  for (let i = 0; i < this.rows.length; i++) {
215
258
  const row = this.rows[i];
216
- row.onclick = () => {
259
+
260
+ const clickHandler = () => {
217
261
  const parsed = {
218
262
  section: getAttributeValue(row, "section"),
219
263
  row: getAttributeValue(row, "row"),
@@ -245,6 +289,13 @@ export default class FilterBuilder {
245
289
  });
246
290
  };
247
291
 
292
+ // Support both click and touch events
293
+ row.onclick = clickHandler;
294
+ row.ontouchend = (e) => {
295
+ e.preventDefault();
296
+ clickHandler();
297
+ };
298
+
248
299
  row.onmouseover = () => {
249
300
  const parsed = {
250
301
  section: getAttributeValue(row, "section"),
@@ -252,6 +303,18 @@ export default class FilterBuilder {
252
303
  id: getAttributeValue(row, "id")
253
304
  };
254
305
 
306
+ const sectionKey = `${parsed.section}`;
307
+
308
+ // Skip if already highlighting this section - prevents flickering
309
+ if (lastHighlightedSection === sectionKey) return;
310
+
311
+ // Only clear if switching sections
312
+ if (lastHighlightedSection && lastHighlightedSection !== sectionKey) {
313
+ this.clearHighlight();
314
+ }
315
+
316
+ lastHighlightedSection = sectionKey;
317
+
255
318
  const matchingFilter = this.filters.find(
256
319
  (f) =>
257
320
  this.isForCurrentEvent(f) &&
@@ -266,6 +329,7 @@ export default class FilterBuilder {
266
329
  };
267
330
 
268
331
  row.onmouseout = () => {
332
+ lastHighlightedSection = null;
269
333
  if (!this.isDragging) this.clearHighlight();
270
334
  };
271
335
  }
@@ -360,37 +424,39 @@ export default class FilterBuilder {
360
424
  this.cssClasses = BASE_CSS;
361
425
  const gaSectionNameMapping = this.getSectionNameMapping();
362
426
 
363
- this.filters.forEach((filter) => {
427
+ this.filters.forEach((filter, filterIndex) => {
364
428
  if (!this.isForCurrentEvent(filter)) return;
365
429
 
366
- const tryFindRealName = Object.entries(gaSectionNameMapping).find(
367
- ([, v]) => v === filter.section && v.includes(" ")
430
+ // Try to find the short name (attribute name) from the real name (stored in filter)
431
+ const shortName = Object.entries(gaSectionNameMapping).find(
432
+ ([, v]) => v === filter.section
368
433
  )?.[0];
369
434
 
370
- if (tryFindRealName) {
371
- log(`Real name for ${filter.section}: ${tryFindRealName}`);
372
- } else {
373
- log(filter.section, "is already real name", gaSectionNameMapping);
435
+ // Check if this is a GA section by looking for paths with this name attribute
436
+ const isGA = shortName && document.querySelectorAll(`path[name="${shortName}"][generaladmission="true"]`)?.length > 0;
437
+
438
+ if (shortName && isGA) {
439
+ log(`GA section detected: ${filter.section} -> short name: ${shortName}`);
374
440
  }
375
441
 
376
442
  const type = this.getFilterType(filter);
377
443
 
378
- let color;
379
- if (filter.exclude) {
380
- color = this.expandedFilter === filter.id ? colors.EXCLUDED_EXPANDED : colors.EXCLUDED;
381
- } else {
382
- color = this.expandedFilter === filter.id ? colors.SELECTED_EXPANDED : colors.SELECTED;
383
- }
444
+ // Use unique color for each filter
445
+ const color = getFilterColor(
446
+ filterIndex,
447
+ this.expandedFilter === filter.id,
448
+ filter.exclude
449
+ );
384
450
 
385
451
  switch (type) {
386
452
  case this.filterTypes.NORMAL:
387
453
  // If it has no 'rows' property
388
454
  if (!Array.isArray(filter.rows)) {
389
- const isGA = document.querySelectorAll(`path[section='${filter.section}']`)?.length === 0;
390
-
391
- if (isGA && tryFindRealName) {
392
- this.cssClasses += `.svg-wrapper path[name="${tryFindRealName}"] {fill: ${color} !important;}\n`;
455
+ if (isGA && shortName) {
456
+ // GA sections use 'name' attribute and 'fill' for styling
457
+ this.cssClasses += `.svg-wrapper path[name="${shortName}"][generaladmission="true"] {fill: ${color} !important;}\n`;
393
458
  } else {
459
+ // Regular sections use 'section' attribute and 'stroke' for styling
394
460
  this.cssClasses += `.svg-wrapper path[section="${filter.section}"] {stroke: ${color} !important;}\n`;
395
461
  }
396
462
  } else {
@@ -440,7 +506,7 @@ export default class FilterBuilder {
440
506
  const label = labels[i];
441
507
  const section = getAttributeValue(label, "section");
442
508
 
443
- label.onclick = () => {
509
+ const labelClickHandler = () => {
444
510
  // filter exists already for section - return
445
511
  const matchingFilter = this.filters.find((f) => {
446
512
  if (!this.isForCurrentEvent(f)) return false;
@@ -488,6 +554,13 @@ export default class FilterBuilder {
488
554
  log("Could not add labelHandler", section, e);
489
555
  }
490
556
  };
557
+
558
+ // Support both click and touch
559
+ label.onclick = labelClickHandler;
560
+ label.ontouchend = (e) => {
561
+ e.preventDefault();
562
+ labelClickHandler();
563
+ };
491
564
  }
492
565
  }
493
566
 
@@ -504,7 +577,7 @@ export default class FilterBuilder {
504
577
  const section = getAttributeValue(path, "name");
505
578
  const sectionName = gaSectionNameMapping[section];
506
579
 
507
- path.onclick = () => {
580
+ const gaClickHandler = () => {
508
581
  // filter exists already for section
509
582
  const matchingFilter = this.filters.find((f) => f.section === sectionName && this.isForCurrentEvent(f));
510
583
  if (matchingFilter) {
@@ -523,6 +596,13 @@ export default class FilterBuilder {
523
596
  event: this.currentEventId
524
597
  });
525
598
  };
599
+
600
+ // Support both click and touch
601
+ path.onclick = gaClickHandler;
602
+ path.ontouchend = (e) => {
603
+ e.preventDefault();
604
+ gaClickHandler();
605
+ };
526
606
  }
527
607
  }
528
608
 
@@ -0,0 +1,97 @@
1
+ import BaseRenderer from "../base-renderer.js";
2
+ import { getEventData, getEventDataUrl } from "./requests.js";
3
+
4
+ class AXSRenderer extends BaseRenderer {
5
+ constructor(eventId, options = {}) {
6
+ super(eventId, options);
7
+ this.config = this.getDefaultConfig();
8
+ }
9
+
10
+ async render() {
11
+ if (!this.storage) {
12
+ this.logger.Error("Cannot render without storage");
13
+ return false;
14
+ }
15
+
16
+ try {
17
+ const data = await this.getEventData();
18
+ this.logger.Debug("Event data loaded");
19
+
20
+ if (this.fs) {
21
+ this.fs.writeFileSync("out.json", JSON.stringify(data));
22
+ }
23
+
24
+ let svgBody = "";
25
+
26
+ data.n.forEach((subsection) => {
27
+ let sectionData = "";
28
+ switch (subsection.h.t) {
29
+ case "seat":
30
+ subsection.n.forEach((seat) => {
31
+ sectionData += this.createElement("circle", {
32
+ cx: seat.c[0],
33
+ cy: seat.c[1],
34
+ id: seat.i,
35
+ r: "0.5",
36
+ fill: "#fff",
37
+ });
38
+ });
39
+ break;
40
+ case "section":
41
+ subsection.n.forEach((polygon) => {
42
+ sectionData += this.createElement(
43
+ "g",
44
+ {
45
+ id: polygon.i,
46
+ transform: `translate(${polygon.c[0]} ${polygon.c[1]})`,
47
+ },
48
+ this.createElement("path", {
49
+ d: polygon.pt,
50
+ fill: "rgba(100,100,100,0.5)",
51
+ }),
52
+ );
53
+ });
54
+ break;
55
+ default:
56
+ break;
57
+ }
58
+ svgBody += this.createElement("g", {}, sectionData);
59
+ });
60
+
61
+ this.svg = this.createElement(
62
+ "svg",
63
+ {
64
+ width: "1440",
65
+ height: "816",
66
+ viewBox: "0 0 1200 680",
67
+ xmlns: "http://www.w3.org/2000/svg",
68
+ version: "1.1",
69
+ },
70
+ svgBody,
71
+ );
72
+
73
+ if (this.fs) {
74
+ this.fs.writeFileSync("test.svg", this.svg);
75
+ }
76
+
77
+ return true;
78
+ } catch (e) {
79
+ this.logger.Error(`Couldn't render ${this.eventId} ${e.message}`);
80
+ return false;
81
+ }
82
+ }
83
+
84
+ async getEventData() {
85
+ return await getEventData(this.eventId, this.options.proxy, this.logger);
86
+ }
87
+
88
+ async getEventDataUrl() {
89
+ return await getEventDataUrl(this.eventId, this.options.proxy, this.logger);
90
+ }
91
+
92
+ async fetchBackgroundImage() {
93
+ throw new Error("Background image fetching not implemented for AXS renderer");
94
+ }
95
+ }
96
+
97
+ export default AXSRenderer;
@@ -0,0 +1,37 @@
1
+ import { getJsonDataWithHeaders } from "../request-utils.js";
2
+
3
+ function buildAXSEventDataUrl(eventId, data) {
4
+ const base = data.url.replace("*", "");
5
+ const policy = data.keys["CloudFront-Policy"];
6
+ const sig = data.keys["CloudFront-Signature"];
7
+ const keypair = data.keys["CloudFront-Key-Pair-Id"];
8
+ const version = "2.6.16";
9
+ return `${base}maps/main/master_full.json?Policy=${policy}_&Signature=${sig}&Key-Pair-Id=${keypair}&v=${version}`;
10
+ }
11
+
12
+ async function getEventDataUrl(eventId, proxy, logger) {
13
+ const url = `https://contentdistribution.3ddvapis.com/api/v1/dvm/token/venue/${eventId}`;
14
+ const customHeaders = {
15
+ "referer": "https://tickets-de.axs.com/",
16
+ "user-agent":
17
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36",
18
+ };
19
+
20
+ return await getJsonDataWithHeaders(url, customHeaders, proxy, 0, logger);
21
+ }
22
+
23
+ async function getEventData(eventId, proxy, logger) {
24
+ const urlData = await getEventDataUrl(eventId, proxy, logger);
25
+ if (!urlData) {
26
+ throw new Error("Failed to get event data URL");
27
+ }
28
+
29
+ const url = buildAXSEventDataUrl(eventId, urlData);
30
+ return await getJsonDataWithHeaders(url, {}, proxy, 0, logger);
31
+ }
32
+
33
+ export {
34
+ getEventData,
35
+ getEventDataUrl,
36
+ buildAXSEventDataUrl,
37
+ };
@@ -0,0 +1,171 @@
1
+ import { createLogger } from "./dependencies/logger.js";
2
+ import * as utils from "./utils.js";
3
+
4
+ class BaseRenderer {
5
+ constructor(eventId, options = {}) {
6
+ this.eventId = eventId;
7
+ this.options = {
8
+ proxy: null,
9
+ country: null,
10
+ ...options,
11
+ };
12
+
13
+ this.logger = createLogger([this.constructor.name, this.eventId]);
14
+ this.storage = null;
15
+ this.sharp = null;
16
+ this.fs = null;
17
+ this.svg = null;
18
+ this.bgImg = null;
19
+ }
20
+
21
+ init(dependencies) {
22
+ this.storage = dependencies.storage;
23
+ this.sharp = dependencies.sharp;
24
+ this.fs = dependencies.fs;
25
+ return this;
26
+ }
27
+
28
+ setHighlightedSeats(placeIds, color = null) {
29
+ if (!this.highlighted) {
30
+ this.highlighted = [];
31
+ }
32
+
33
+ const seatsToAdd = (placeIds || []).map((id) => ({
34
+ id,
35
+ color: color || this.getDefaultConfig().highlightedSeatColor,
36
+ }));
37
+
38
+ this.highlighted.push(...seatsToAdd);
39
+ return this;
40
+ }
41
+
42
+ setCustomConfig(config) {
43
+ this.config = {
44
+ ...this.getDefaultConfig(),
45
+ ...config,
46
+ };
47
+ return this;
48
+ }
49
+
50
+ getDefaultConfig() {
51
+ return {
52
+ // Basic rendering options
53
+ outputWidth: 2048,
54
+ includeDetailedAttributes: false,
55
+ logFullError: true,
56
+
57
+ // Colors
58
+ seatColor: "#0557ae",
59
+ nonAvSeatColor: "#dadcde",
60
+ highlightedSeatColor: "#d0006f",
61
+ GAColor: "#61c6c2",
62
+ nonAvGAColor: "#b4babe",
63
+ grayedOutSection: "#a3a3a3",
64
+
65
+ // Text styling
66
+ labelColor: "#000",
67
+ labelColorOnGA: "#fff",
68
+ textOpacity: 0.8,
69
+ textOpacityOnGA: 1,
70
+ fontSize: "1em",
71
+ font: "sans-serif",
72
+ useBig: true,
73
+
74
+ // Rendering behavior
75
+ dontRenderUnusedSections: false,
76
+ dontRenderSeats: false,
77
+ seatsAsPins: false,
78
+ renderRowBlocks: false,
79
+ increaseHighlightedSeatSize: 6,
80
+ };
81
+ }
82
+
83
+ async render() {
84
+ throw new Error("render() method must be implemented by subclass");
85
+ }
86
+
87
+ async renderSeatmap(priceData) {
88
+ // Store price data for subclasses to use
89
+ this.priceData = priceData;
90
+ throw new Error("renderSeatmap() method must be implemented by subclass");
91
+ }
92
+
93
+ async outputToSVG(path) {
94
+ if (!this.svg || !this.fs) {
95
+ this.logger.Error("No SVG data available or fs not initialized");
96
+ return this;
97
+ }
98
+ this.fs.writeFileSync(path, this.svg);
99
+ return this;
100
+ }
101
+
102
+ async outputToPNG(filePath) {
103
+ if (!this.svg || !this.sharp) {
104
+ this.logger.Error("No SVG data available or sharp not initialized");
105
+ return this;
106
+ }
107
+ const s = await this._png();
108
+ await s.toFile(filePath);
109
+ return this;
110
+ }
111
+
112
+ async outputToPNGBuffer() {
113
+ if (!this.svg || !this.sharp) {
114
+ return Buffer.from([0x00]);
115
+ }
116
+ const s = await this._png();
117
+ return s.toBuffer();
118
+ }
119
+
120
+ async _png() {
121
+ let background = await this.storage.getItem(`renderer.background.${this.eventId}`);
122
+ if (!background) {
123
+ try {
124
+ background = await this._fetchSaveBgImage();
125
+ } catch (e) {
126
+ this.logger.Error(e.message);
127
+ }
128
+ } else if (background) {
129
+ if (typeof background === "string" && background.startsWith("{")) {
130
+ background = JSON.parse(background);
131
+ }
132
+ background = Buffer.from(background);
133
+ const bgData = await this.sharp(background).metadata();
134
+ if (bgData.width !== this.config.outputWidth) {
135
+ this.config.outputWidth = bgData.width;
136
+ }
137
+ }
138
+
139
+ const svgBuffer = Buffer.from(this.svg);
140
+ const png = await this.sharp(svgBuffer).png().resize(this.config.outputWidth).toBuffer();
141
+
142
+ if (background) {
143
+ const foreground = await this.sharp(png).toBuffer();
144
+ return this.sharp(background).composite([
145
+ {
146
+ input: foreground,
147
+ gravity: "southeast",
148
+ },
149
+ ]);
150
+ } else {
151
+ return this.sharp(png);
152
+ }
153
+ }
154
+
155
+ async _fetchSaveBgImage() {
156
+ const bgImgData = await this.fetchBackgroundImage();
157
+ const background = await this.sharp(bgImgData).png().resize(this.config.outputWidth).toBuffer();
158
+ await this.storage.setItem(`renderer.background.${this.eventId}`, background);
159
+ return background;
160
+ }
161
+
162
+ createElement(tag, attrs, body = "") {
163
+ return utils.createElement(tag, attrs, body);
164
+ }
165
+
166
+ async fetchBackgroundImage() {
167
+ throw new Error("fetchBackgroundImage() method must be implemented by subclass");
168
+ }
169
+ }
170
+
171
+ export default BaseRenderer;