vaporous 0.0.3 → 0.0.4
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/Vaporous.js +190 -86
- package/package.json +1 -1
- package/readme.md +22 -22
- package/styles.css +82 -78
- package/types/Aggregation.js +2 -2
package/Vaporous.js
CHANGED
|
@@ -245,11 +245,19 @@ class Vaporous {
|
|
|
245
245
|
const arr = Object.keys(map).map(key => {
|
|
246
246
|
const result = map[key]
|
|
247
247
|
|
|
248
|
+
let sortedCache = {}
|
|
248
249
|
aggregations.forEach(aggregation => {
|
|
249
|
-
|
|
250
|
+
const outputField = aggregation.outputField
|
|
251
|
+
const reference = map[key]._statsRaw[aggregation.field]
|
|
252
|
+
|
|
253
|
+
if (aggregation.sortable) {
|
|
254
|
+
sortedCache[aggregation.field] = reference.slice().sort((a, b) => a - b)
|
|
255
|
+
result[outputField] = aggregation.calculate(sortedCache[aggregation.field])
|
|
256
|
+
} else {
|
|
257
|
+
result[outputField] = aggregation.calculate(reference)
|
|
258
|
+
}
|
|
259
|
+
|
|
250
260
|
|
|
251
|
-
const aggregationField = aggregation.outputField
|
|
252
|
-
result[aggregationField] = aggregation.calculate(map[key])
|
|
253
261
|
})
|
|
254
262
|
|
|
255
263
|
delete map[key]._statsRaw
|
|
@@ -345,11 +353,154 @@ class Vaporous {
|
|
|
345
353
|
return this;
|
|
346
354
|
}
|
|
347
355
|
|
|
348
|
-
build(name, type, { tab = 'Default', columns = 2 } = {}) {
|
|
356
|
+
build(name, type, { tab = 'Default', columns = 2, y2, y1Type, y2Type, y1Stacked, y2Stacked, sortX = 'asc', xTicks = false, trellisAxis = "shared" } = {}) {
|
|
349
357
|
|
|
350
358
|
const visualisationOptions = { tab, columns }
|
|
351
359
|
|
|
352
|
-
|
|
360
|
+
|
|
361
|
+
let bounds = {}
|
|
362
|
+
|
|
363
|
+
const isY2 = (data) => {
|
|
364
|
+
let y2Mapped = false;
|
|
365
|
+
|
|
366
|
+
if (y2 instanceof Array) {
|
|
367
|
+
y2Mapped = y2.includes(data)
|
|
368
|
+
}
|
|
369
|
+
else if (y2 instanceof RegExp) {
|
|
370
|
+
y2Mapped = y2.test(data)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return y2Mapped
|
|
374
|
+
}
|
|
375
|
+
const graphData = this.events.map((trellis, i) => {
|
|
376
|
+
if (type === 'Table') {
|
|
377
|
+
return trellis;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const dataOptions = {}
|
|
381
|
+
|
|
382
|
+
// For every event in this trellis restructure to chart.js
|
|
383
|
+
if (sortX) trellis = _sort(sortX, trellis, '_time')
|
|
384
|
+
|
|
385
|
+
const trellisName = this.graphFlags.at(-1).trellisName?.[i] || ""
|
|
386
|
+
const columnDefinitions = this.graphFlags.at(-1).columnDefinitions[i]
|
|
387
|
+
|
|
388
|
+
trellis.forEach(event => {
|
|
389
|
+
columnDefinitions.forEach(prop => {
|
|
390
|
+
if (!dataOptions[prop]) dataOptions[prop] = []
|
|
391
|
+
const val = event[prop]
|
|
392
|
+
dataOptions[prop].push(val)
|
|
393
|
+
|
|
394
|
+
if (!bounds[prop]) bounds[prop] = {
|
|
395
|
+
min: val,
|
|
396
|
+
max: val
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (val < bounds[prop].min) bounds[prop].min = val;
|
|
400
|
+
if (val > bounds[prop].max) bounds[prop].max = val
|
|
401
|
+
|
|
402
|
+
})
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
const _time = dataOptions._time
|
|
407
|
+
delete dataOptions._time
|
|
408
|
+
|
|
409
|
+
let y2WasMapped = false
|
|
410
|
+
const data = {
|
|
411
|
+
labels: _time,
|
|
412
|
+
datasets: Object.keys(dataOptions).map(data => {
|
|
413
|
+
const y2Mapped = isY2(data)
|
|
414
|
+
if (y2Mapped) y2WasMapped = y2Mapped
|
|
415
|
+
|
|
416
|
+
const base = {
|
|
417
|
+
label: data,
|
|
418
|
+
yAxisID: y2Mapped ? 'y2' : undefined,
|
|
419
|
+
data: dataOptions[data],
|
|
420
|
+
type: y2Mapped ? y2Type : y1Type,
|
|
421
|
+
// borderColor: 'red',
|
|
422
|
+
// backgroundColor: 'red',
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (type === 'Scatter') {
|
|
426
|
+
base.showLine = false
|
|
427
|
+
base.pointRadius = 8
|
|
428
|
+
base.pointStyle = 'rect'
|
|
429
|
+
} else if (type === 'Area') {
|
|
430
|
+
base.fill = 'origin'
|
|
431
|
+
} else if (type === 'Line') {
|
|
432
|
+
base.pointRadius = 0;
|
|
433
|
+
}
|
|
434
|
+
return base
|
|
435
|
+
})
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const scales = {
|
|
439
|
+
y: {
|
|
440
|
+
type: 'linear',
|
|
441
|
+
display: true,
|
|
442
|
+
position: 'left',
|
|
443
|
+
stacked: y1Stacked
|
|
444
|
+
},
|
|
445
|
+
x: {
|
|
446
|
+
type: 'linear',
|
|
447
|
+
ticks: {
|
|
448
|
+
display: xTicks
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (y2WasMapped) scales.y2 = {
|
|
454
|
+
type: 'linear',
|
|
455
|
+
display: true,
|
|
456
|
+
position: 'right',
|
|
457
|
+
grid: {
|
|
458
|
+
drawOnChartArea: false
|
|
459
|
+
},
|
|
460
|
+
stacked: y2Stacked
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
type: 'line',
|
|
465
|
+
data: data,
|
|
466
|
+
options: {
|
|
467
|
+
scales,
|
|
468
|
+
responsive: true,
|
|
469
|
+
plugins: {
|
|
470
|
+
legend: {
|
|
471
|
+
position: 'bottom',
|
|
472
|
+
},
|
|
473
|
+
title: {
|
|
474
|
+
display: true,
|
|
475
|
+
text: name + trellisName
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
if (trellisAxis === 'shared') {
|
|
483
|
+
// Do a second iteration to implement bounds
|
|
484
|
+
graphData.forEach(trellisGraph => {
|
|
485
|
+
Object.keys(bounds).forEach(bound => {
|
|
486
|
+
let axis = isY2(bound) ? 'y2' : 'y'
|
|
487
|
+
if (bound === '_time') axis = 'x';
|
|
488
|
+
|
|
489
|
+
const thisAxis = trellisGraph.options.scales[axis]
|
|
490
|
+
const { min, max } = bounds[bound]
|
|
491
|
+
if (!thisAxis.min) {
|
|
492
|
+
thisAxis.min = min
|
|
493
|
+
thisAxis.max = max
|
|
494
|
+
}
|
|
495
|
+
if (min < thisAxis.min) thisAxis.min = min
|
|
496
|
+
if (max > thisAxis.max) thisAxis.max = max
|
|
497
|
+
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const data = JSON.stringify(graphData)
|
|
353
504
|
const lastData = this.visualisationData.at(-1)
|
|
354
505
|
|
|
355
506
|
if (lastData !== data) this.visualisationData.push(data)
|
|
@@ -397,12 +548,13 @@ class Vaporous {
|
|
|
397
548
|
return this;
|
|
398
549
|
}
|
|
399
550
|
|
|
400
|
-
toGraph(x, y, series, trellis = false
|
|
551
|
+
toGraph(x, y, series, trellis = false) {
|
|
401
552
|
|
|
402
553
|
if (!(y instanceof Array)) y = [y]
|
|
403
|
-
if (options.y2 instanceof RegExp) options.y2 = options.y2.toString()
|
|
404
554
|
|
|
405
|
-
const yAggregations = y.map(item =>
|
|
555
|
+
const yAggregations = y.map(item => [
|
|
556
|
+
new Aggregation(item, 'list', item),
|
|
557
|
+
]).flat()
|
|
406
558
|
|
|
407
559
|
this.stats(
|
|
408
560
|
...yAggregations,
|
|
@@ -422,7 +574,14 @@ class Vaporous {
|
|
|
422
574
|
|
|
423
575
|
event[series].forEach((series, i) => {
|
|
424
576
|
y.forEach(item => {
|
|
425
|
-
|
|
577
|
+
let name;
|
|
578
|
+
if (y.length === 1) {
|
|
579
|
+
if (series === undefined) name = item
|
|
580
|
+
else name = series
|
|
581
|
+
} else {
|
|
582
|
+
if (series !== undefined) name = `${series}_${item}`
|
|
583
|
+
else name = item
|
|
584
|
+
}
|
|
426
585
|
obj[name] = event[item][i]
|
|
427
586
|
})
|
|
428
587
|
})
|
|
@@ -447,6 +606,7 @@ class Vaporous {
|
|
|
447
606
|
})
|
|
448
607
|
|
|
449
608
|
const graphFlags = {}
|
|
609
|
+
|
|
450
610
|
if (trellis) {
|
|
451
611
|
graphFlags.trellis = true;
|
|
452
612
|
graphFlags.trellisName = Object.keys(trellisMap)
|
|
@@ -464,23 +624,14 @@ class Vaporous {
|
|
|
464
624
|
graphFlags.columnDefinitions = [adjColumns]
|
|
465
625
|
}
|
|
466
626
|
|
|
467
|
-
Object.assign(graphFlags, options)
|
|
468
627
|
this.graphFlags.push(graphFlags)
|
|
469
628
|
return this;
|
|
470
629
|
}
|
|
471
630
|
|
|
472
|
-
render() {
|
|
631
|
+
render(location = './Vaporous_generation.html') {
|
|
473
632
|
const classSafe = (name) => name.replace(/[^a-zA-Z0-9]/g, "_")
|
|
474
633
|
|
|
475
|
-
const createElement = (name, type, visualisationOptions, eventData, { trellis,
|
|
476
|
-
|
|
477
|
-
if (typeof y2 === 'string') {
|
|
478
|
-
y2 = y2.split("/")
|
|
479
|
-
const flags = y2.at(-1)
|
|
480
|
-
y2.pop()
|
|
481
|
-
const content = y2.splice(1).join("/")
|
|
482
|
-
y2 = new RegExp(content, flags)
|
|
483
|
-
}
|
|
634
|
+
const createElement = (name, type, visualisationOptions, eventData, { trellis, trellisName = "" }) => {
|
|
484
635
|
|
|
485
636
|
if (classSafe(visualisationOptions.tab) !== selectedTab) return;
|
|
486
637
|
|
|
@@ -496,88 +647,41 @@ class Vaporous {
|
|
|
496
647
|
eventData = pairs.map(p => p[1]);
|
|
497
648
|
}
|
|
498
649
|
|
|
499
|
-
|
|
500
|
-
const data = new google.visualization.DataTable();
|
|
501
|
-
|
|
502
|
-
const series = {}, axis0 = { targetAxisIndex: 0 }, axis1 = { targetAxisIndex: 1 }
|
|
503
|
-
|
|
504
|
-
if (y1Type) axis0.type = y1Type
|
|
505
|
-
if (y2Type) axis1.type = y2Type
|
|
506
|
-
|
|
507
|
-
// Create columns
|
|
508
|
-
const columns = columnDefinitions[i]
|
|
509
|
-
|
|
510
|
-
columns.forEach((key, i) => {
|
|
511
|
-
// TODO: we might have to iterate the dataseries to find this information - most likely update the column definition references
|
|
512
|
-
const colType = typeof trellisData[0][key]
|
|
513
|
-
data.addColumn(colType === 'undefined' ? "number" : colType, key)
|
|
514
|
-
|
|
515
|
-
if (y2 && i !== 0) {
|
|
516
|
-
let match = false;
|
|
517
|
-
if (y2 instanceof Array) { match = y2.includes(key) }
|
|
518
|
-
else if (y2 instanceof RegExp) { match = y2.test(key) }
|
|
519
|
-
|
|
520
|
-
if (match) series[i - 1] = axis1
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
if (!series[i - 1]) series[i - 1] = axis0
|
|
524
|
-
})
|
|
525
|
-
|
|
526
|
-
let rows = trellisData.map(event => {
|
|
527
|
-
return columns.map(key => event[key])
|
|
528
|
-
})
|
|
529
|
-
|
|
530
|
-
rows = _sort(sortX, rows, 0)
|
|
531
|
-
|
|
532
|
-
data.addRows(rows);
|
|
533
|
-
|
|
534
|
-
const columnCount = visualisationOptions.columns || 2
|
|
535
|
-
const thisEntity = document.createElement('div')
|
|
536
|
-
thisEntity.className = "parentHolder"
|
|
537
|
-
thisEntity.style = `flex: 1 0 calc(${100 / columns}% - 6px); max-width: calc(${100 / columnCount}% - 6px);`
|
|
650
|
+
const columnCount = visualisationOptions.columns || 2
|
|
538
651
|
|
|
652
|
+
eventData.forEach((trellisData, i) => {
|
|
653
|
+
const parentHolder = document.createElement('div')
|
|
539
654
|
|
|
540
|
-
const thisGraph = document.createElement('div')
|
|
541
|
-
thisGraph.className = "graphHolder"
|
|
542
|
-
thisEntity.appendChild(thisGraph)
|
|
543
|
-
document.getElementById('content').appendChild(thisEntity)
|
|
544
655
|
|
|
545
|
-
const chartElement = new google.visualization[type](thisGraph)
|
|
546
656
|
|
|
547
|
-
|
|
548
|
-
console.log(chartElement.getSelection()[1], chartElement.getSelection()[0])
|
|
549
|
-
tokens[name] = trellisData[chartElement.getSelection()[0].row]
|
|
550
|
-
console.log(tokens[name])
|
|
551
|
-
});
|
|
657
|
+
document.getElementById('content').appendChild(parentHolder)
|
|
552
658
|
|
|
553
|
-
|
|
659
|
+
parentHolder.style = `flex: 0 0 calc(${100 / columnCount}% - 8px); max-width: calc(${100 / columnCount}% - 8px);`
|
|
660
|
+
if (type === 'Table') {
|
|
661
|
+
new Tabulator(parentHolder, { data: trellisData, autoColumns: 'full', layout: "fitDataStretch", })
|
|
662
|
+
} else {
|
|
663
|
+
const graphEntity = document.createElement('canvas')
|
|
664
|
+
parentHolder.appendChild(graphEntity)
|
|
665
|
+
new Chart(graphEntity, trellisData)
|
|
666
|
+
}
|
|
554
667
|
|
|
555
|
-
chartElement.draw(data, {
|
|
556
|
-
series, showRowNumber: false, legend: { position: 'bottom' }, title, isStacked: stacked,
|
|
557
|
-
width: document.body.scrollWidth / columnCount - (type === "LineChart" ? 12 : 24),
|
|
558
|
-
animation: { duration: 500, startup: true },
|
|
559
|
-
chartArea: { width: '85%', height: '75%' },
|
|
560
|
-
vAxis: {
|
|
561
|
-
viewWindow: {
|
|
562
|
-
min: y1Min
|
|
563
|
-
}
|
|
564
|
-
},
|
|
565
|
-
pointSize: type === 'ScatterChart' ? 2 : undefined
|
|
566
|
-
})
|
|
567
668
|
})
|
|
568
669
|
}
|
|
569
670
|
|
|
570
|
-
const filePath =
|
|
671
|
+
const filePath = location
|
|
571
672
|
fs.writeFileSync(filePath, `
|
|
572
673
|
<html>
|
|
573
674
|
<head>
|
|
675
|
+
<meta name="viewport" content="width=device-width, initial-scale=0.5">
|
|
574
676
|
<style>
|
|
575
677
|
${styles}
|
|
576
678
|
</style>
|
|
577
|
-
|
|
679
|
+
|
|
680
|
+
<link href="https://unpkg.com/tabulator-tables@6.3.1/dist/css/tabulator.min.css" rel="stylesheet">
|
|
681
|
+
<script type="text/javascript" src="https://unpkg.com/tabulator-tables@6.3.1/dist/js/tabulator.min.js"></script>
|
|
682
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
683
|
+
|
|
578
684
|
<script type="text/javascript">
|
|
579
|
-
google.charts.load('current', {'packages':['table', 'corechart']});
|
|
580
|
-
google.charts.setOnLoadCallback(drawVis);
|
|
581
685
|
|
|
582
686
|
const classSafe = ${classSafe.toString()}
|
|
583
687
|
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
# Vaporous
|
|
2
|
-
Vaporous provides a chained query syntax for accessing unstructured data and converting it into interpretable analytics
|
|
3
|
-
|
|
4
|
-
The tool is still in its early phases of development and is missing quality of life features
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
## Examples
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
- [
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
## TODO List
|
|
18
|
-
- Support web page embedded Vaporous so clients can use browser folder storage as file input
|
|
19
|
-
- Add an error for if a user tries to generate a graph without first calling toGraph
|
|
20
|
-
- Intercept structual errors earlier and add validation to functions - not necessarily data as this casues overhaead
|
|
21
|
-
- Migrate reponsibility for tabular conversion from create element to the primary library to reduce overhead of graph generation
|
|
22
|
-
|
|
1
|
+
# Vaporous
|
|
2
|
+
Vaporous provides a chained query syntax for accessing unstructured data and converting it into interpretable analytics
|
|
3
|
+
|
|
4
|
+
The tool is still in its early phases of development and is missing some quality of life features
|
|
5
|
+
|
|
6
|
+
The query syntax is heavily inspired by splunk with more bias towards programmitic functionality
|
|
7
|
+
|
|
8
|
+
## Examples
|
|
9
|
+
|
|
10
|
+
Interactive previews for two datasources are available
|
|
11
|
+
|
|
12
|
+
- [Virtualised temperature sensor data](https://lkashl.github.io/vaporous/pages/temp_sensors.html)
|
|
13
|
+
- [CSV delimited virtruvian data](https://lkashl.github.io/vaporous/pages/gym.html)
|
|
14
|
+
|
|
15
|
+
Examples of the source queries used can be referenced in the [examples folder](https://github.com/lkashl/vaporous/tree/main/examples)
|
|
16
|
+
|
|
17
|
+
## TODO List
|
|
18
|
+
- Support web page embedded Vaporous so clients can use browser folder storage as file input
|
|
19
|
+
- Add an error for if a user tries to generate a graph without first calling toGraph
|
|
20
|
+
- Intercept structual errors earlier and add validation to functions - not necessarily data as this casues overhaead
|
|
21
|
+
- Migrate reponsibility for tabular conversion from create element to the primary library to reduce overhead of graph generation
|
|
22
|
+
|
package/styles.css
CHANGED
|
@@ -1,79 +1,83 @@
|
|
|
1
|
-
@import url('https://fonts.googleapis.com/css?family=Roboto:400,700&display=swap');
|
|
2
|
-
|
|
3
|
-
body {
|
|
4
|
-
font-family: 'Roboto', Arial, sans-serif;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
/* Chart-specific styles */
|
|
8
|
-
.chart,
|
|
9
|
-
.chart-container,
|
|
10
|
-
canvas,
|
|
11
|
-
svg,
|
|
12
|
-
.chartjs-render-monitor {
|
|
13
|
-
font-family: 'Roboto', Arial, sans-serif !important;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
body {
|
|
17
|
-
margin: 0;
|
|
18
|
-
padding:
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
.tabBar {
|
|
22
|
-
display: flex;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
margin-bottom: 8px;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
border:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
1
|
+
@import url('https://fonts.googleapis.com/css?family=Roboto:400,700&display=swap');
|
|
2
|
+
|
|
3
|
+
body {
|
|
4
|
+
font-family: 'Roboto', Arial, sans-serif;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/* Chart-specific styles */
|
|
8
|
+
.chart,
|
|
9
|
+
.chart-container,
|
|
10
|
+
canvas,
|
|
11
|
+
svg,
|
|
12
|
+
.chartjs-render-monitor {
|
|
13
|
+
font-family: 'Roboto', Arial, sans-serif !important;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
body {
|
|
17
|
+
margin: 0;
|
|
18
|
+
padding: 16px;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.tabBar {
|
|
22
|
+
display: flex;
|
|
23
|
+
overflow: hidden;
|
|
24
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
|
|
25
|
+
min-height: 32px;
|
|
26
|
+
margin-bottom: 8px;
|
|
27
|
+
margin-top: 8px;
|
|
28
|
+
margin-left: 8px;
|
|
29
|
+
margin-right: 8px;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.tabs {
|
|
33
|
+
border-radius: 8px;
|
|
34
|
+
padding: 8px 20px;
|
|
35
|
+
cursor: pointer;
|
|
36
|
+
background: none;
|
|
37
|
+
border: none;
|
|
38
|
+
outline: none;
|
|
39
|
+
font-size: 1rem;
|
|
40
|
+
color: #555;
|
|
41
|
+
transition: background 0.3s, color 0.3s;
|
|
42
|
+
text-align: center;
|
|
43
|
+
margin: 12px;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.tabs:not(.selectedTab):hover {
|
|
47
|
+
background: #e0e0e0;
|
|
48
|
+
color: #222;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.selectedTab {
|
|
52
|
+
background: #1976d2;
|
|
53
|
+
color: #fff;
|
|
54
|
+
font-weight: bold;
|
|
55
|
+
box-shadow: 0 2px 8px rgba(25, 118, 210, 0.15);
|
|
56
|
+
transition: background 0.3s, color 0.3s;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#content {
|
|
60
|
+
display: flex;
|
|
61
|
+
flex-wrap: wrap;
|
|
62
|
+
padding: 0px;
|
|
63
|
+
gap: 8px;
|
|
64
|
+
justify-content: space-between;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.parentHolder {
|
|
68
|
+
display: flex;
|
|
69
|
+
margin: 2px;
|
|
70
|
+
border: 1px solid #d3d3d3;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.parentHolder::after {
|
|
74
|
+
border: 2px solid red;
|
|
75
|
+
/* Change color and width as needed */
|
|
76
|
+
pointer-events: none;
|
|
77
|
+
z-index: 2;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.tabContent {
|
|
81
|
+
opacity: 1;
|
|
82
|
+
transition: opacity 0.3s,
|
|
79
83
|
}
|