diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 996312b7c..b0004f02b 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,5 @@ import { Component, OnInit } from '@angular/core'; +import { ThemeService } from './service/theme.service'; @Component({ selector: 'app-root', @@ -9,6 +10,10 @@ export class AppComponent implements OnInit { title = 'DSOMM'; menuIsOpen: boolean = true; + constructor(private themeService: ThemeService) { + this.themeService.initTheme(); + } + ngOnInit(): void { let menuState: string | null = localStorage.getItem('state.menuIsOpen'); if (menuState === 'false') { diff --git a/src/app/component/circular-heatmap/circular-heatmap.component.css b/src/app/component/circular-heatmap/circular-heatmap.component.css index 2056fa017..51b43e55d 100644 --- a/src/app/component/circular-heatmap/circular-heatmap.component.css +++ b/src/app/component/circular-heatmap/circular-heatmap.component.css @@ -71,16 +71,26 @@ } .overlay-close { - border: black solid 1px; - background-color: rgba(0, 0, 0, 0); border: none; - color: black; + background-color: rgba(0, 0, 0, 0); grid-column: 2/3; grid-row: 1/4; display: grid; - justify-content: top; + justify-content: center; + align-items: start; margin-left: auto; } + +/* overlay-close - light theme */ +:host-context(body.light-theme) .overlay-close { + color: black; +} + +/* overlay-close - dark theme */ +:host-context(body.dark-theme) .overlay-close { + color: white; +} + .team-filter { padding: 0.4rem; grid-column: 2/3; @@ -161,6 +171,6 @@ button.filter-toggle { .overlay-details { width: 100%; - } + } } \ No newline at end of file diff --git a/src/app/component/circular-heatmap/circular-heatmap.component.ts b/src/app/component/circular-heatmap/circular-heatmap.component.ts index 4d737b11f..99d2571d4 100644 --- a/src/app/component/circular-heatmap/circular-heatmap.component.ts +++ b/src/app/component/circular-heatmap/circular-heatmap.component.ts @@ -15,6 +15,7 @@ import { ModalMessageComponent, DialogInfo, } from '../modal-message/modal-message.component'; +import { ThemeService } from '../../service/theme.service'; export interface activitySchema { uuid: string; @@ -63,27 +64,56 @@ export class CircularHeatmapComponent implements OnInit { showOverlay: boolean; showFilters: boolean; markdown: md = md(); + theme: string; + theme_colors!: Record; constructor( private yaml: ymlService, private router: Router, + private themeService: ThemeService, public modal: ModalMessageComponent ) { this.showOverlay = false; this.showFilters = true; + this.theme = this.themeService.getTheme(); } ngOnInit(): void { - console.log(`${this.perfNow()}s: ngOnInit`); - // Ensure that Levels and Teams load before MaturityData - // using promises, since ngOnInit does not support async/await - this.LoadMaturityLevels() - .then(() => this.LoadTeamsFromMetaYaml()) - .then(() => this.LoadMaturityDataFromGeneratedYaml()) - .then(() => { - console.log(`${this.perfNow()}s: set filters: ${this.chips?.length}`); - this.matChipsArray = this.chips.toArray(); - }); + const savedTheme = this.themeService.getTheme() || 'light'; + this.themeService.setTheme(savedTheme); // sets .light-theme or .dark-theme + + requestAnimationFrame(() => { + // Now the DOM has the correct class and CSS vars are live + const css = getComputedStyle(document.body); + this.theme_colors = { + background: css.getPropertyValue('--heatmap-background').trim(), + filled: css.getPropertyValue('--heatmap-filled').trim(), + disabled: css.getPropertyValue('--heatmap-disabled').trim(), + cursor: css.getPropertyValue('--heatmap-cursor').trim(), + stroke: css.getPropertyValue('--heatmap-stroke').trim(), + }; + + this.LoadMaturityLevels() + .then(() => this.LoadTeamsFromMetaYaml()) + .then(() => this.LoadMaturityDataFromGeneratedYaml()) + .then(() => { + this.matChipsArray = this.chips.toArray(); + }); + }); + + // Reactively handle theme changes (if user toggles later) + this.themeService.theme$.subscribe((theme: string) => { + const css = getComputedStyle(document.body); + this.theme_colors = { + background: css.getPropertyValue('--heatmap-background').trim(), + filled: css.getPropertyValue('--heatmap-filled').trim(), + disabled: css.getPropertyValue('--heatmap-disabled').trim(), + cursor: css.getPropertyValue('--heatmap-cursor').trim(), + stroke: css.getPropertyValue('--heatmap-stroke').trim(), + }; + + this.reColorHeatmap(); // repaint segments with new theme + }); } @ViewChildren(MatChip) chips!: QueryList; @@ -405,7 +435,6 @@ export class CircularHeatmapComponent implements OnInit { .innerRadius(innerRadius) .segmentHeight(segmentHeight) .domain([0, 1]) - .range(['white', 'green']) .radialLabels(radial_labels) .segmentLabels(segment_labels); @@ -498,6 +527,7 @@ export class CircularHeatmapComponent implements OnInit { var segmentLabels: any[] = []; //console.log(segmentLabels) + let _self: any = this; function chart(selection: any) { selection.each(function (this: any, data: any) { @@ -548,7 +578,7 @@ export class CircularHeatmapComponent implements OnInit { .startAngle(sa) .endAngle(ea) ) - .attr('stroke', '#252525') + .attr('stroke', _self.theme_colors['stroke']) .attr('fill', function (d) { return color(accessor(d)); }); @@ -610,17 +640,11 @@ export class CircularHeatmapComponent implements OnInit { cursors .append('path') .attr('id', 'hover') - .attr('pointer-events', 'none') - .attr('stroke', 'green') - .attr('stroke-width', '7') - .attr('fill', 'transparent'); + .attr('pointer-events', 'none'); cursors .append('path') .attr('id', 'selected') - .attr('pointer-events', 'none') - .attr('stroke', '#232323') - .attr('stroke-width', '4') - .attr('fill', 'transparent'); + .attr('pointer-events', 'none'); }); } @@ -716,7 +740,7 @@ export class CircularHeatmapComponent implements OnInit { noActivitytoGrey(): void { for (var x = 0; x < this.ALL_CARD_DATA.length; x++) { if (this.ALL_CARD_DATA[x]['Done%'] == -1) { - d3.select('#index-' + x).attr('fill', '#DCDCDC'); + d3.select('#index-' + x).attr('fill', this.theme_colors['disabled']); } } } @@ -822,7 +846,7 @@ export class CircularHeatmapComponent implements OnInit { var colorSector = d3 .scaleLinear() .domain([0, 1]) - .range(['white', 'green']); + .range([this.theme_colors['background'], this.theme_colors['filled']]); if (cntAll !== 0) { this.ALL_CARD_DATA[index]['Done%'] = cntTrue / cntAll; @@ -833,7 +857,10 @@ export class CircularHeatmapComponent implements OnInit { } else { this.ALL_CARD_DATA[index]['Done%'] = -1; // console.log(`${this.ALL_CARD_DATA[index].SubDimension} ${this.ALL_CARD_DATA[index].Level} None`); - d3.select('#index-' + index).attr('fill', '#DCDCDC'); + d3.select('#index-' + index).attr( + 'fill', + this.theme_colors['disabled'] + ); } } } diff --git a/src/app/component/dependency-graph/dependency-graph.component.ts b/src/app/component/dependency-graph/dependency-graph.component.ts index b1e253220..4990ae632 100644 --- a/src/app/component/dependency-graph/dependency-graph.component.ts +++ b/src/app/component/dependency-graph/dependency-graph.component.ts @@ -1,6 +1,8 @@ -import { Component, OnInit, Input } from '@angular/core'; +import { Component, OnInit, Input, ElementRef } from '@angular/core'; import * as d3 from 'd3'; import { ymlService } from 'src/app/service/yaml-parser/yaml-parser.service'; +import { Subscription } from 'rxjs'; +import { ThemeService } from '../../service/theme.service'; export interface graphNodes { id: string; @@ -35,10 +37,24 @@ export class DependencyGraphComponent implements OnInit { @Input() subDimension: string = ''; @Input() activityName: string = ''; - constructor(private yaml: ymlService) {} + private themeSub: Subscription | undefined; + currentTheme: string = 'light'; // default + + constructor( + private yaml: ymlService, + private elementRef: ElementRef, + private themeService: ThemeService + ) {} ngOnInit(): void { this.yaml.setURI('./assets/YAML/generated/generated.yaml'); + + this.currentTheme = this.themeService.getTheme(); + this.themeSub = this.themeService.theme$.subscribe(theme => { + this.currentTheme = theme; + this.applyTextColor(theme); + }); + // Function sets data this.yaml.getJson().subscribe(data => { this.graphData = { nodes: [], links: [] }; @@ -108,7 +124,26 @@ export class DependencyGraphComponent implements OnInit { } } + applyTextColor(theme: string): void { + const fill = theme === 'dark' ? '#ffffff' : '#000000'; + const selectedNodeColor = theme === 'dark' ? '#666666' : 'yellow'; + const defaultNodeColor = this.COLOR_OF_NODE; + + d3.select(this.elementRef.nativeElement) + .selectAll('text') + .attr('fill', fill); + + d3.select(this.elementRef.nativeElement) + .selectAll('circle') + .attr('fill', (d: any) => + d.id === this.activityName ? selectedNodeColor : defaultNodeColor + ); + } + generateGraph(activity: string): void { + const selectedNodeColor = + this.currentTheme === 'dark' ? '#666666' : 'yellow'; + let svg = d3.select('svg'), width = +svg.attr('width'), height = +svg.attr('height'); @@ -162,10 +197,9 @@ export class DependencyGraphComponent implements OnInit { node .append('circle') .attr('r', 10) - .attr('fill', function (d) { - if (d.id == activity) return 'yellow'; - else return defaultNodeColor; - }); + .attr('fill', (d: any) => + d.id === this.activityName ? selectedNodeColor : defaultNodeColor + ); node .append('text') @@ -175,6 +209,8 @@ export class DependencyGraphComponent implements OnInit { return d.id; }); + this.applyTextColor(this.currentTheme); + this.simulation.nodes(this.graphData['nodes']).on('tick', ticked); this.simulation.force('link').links(this.graphData['links']); diff --git a/src/app/component/matrix/matrix.component.css b/src/app/component/matrix/matrix.component.css index 34c39e3dd..7351c6508 100644 --- a/src/app/component/matrix/matrix.component.css +++ b/src/app/component/matrix/matrix.component.css @@ -61,9 +61,20 @@ .tags-activity { font-weight: 800; font-style: italic; - color: rgb(0, 113, 151); font-size: 12px; } +/*tag activity - light */ +:host-context(body.light-theme) .tags-activity, +:host-context(body.light-theme) .tags-activity span { + color: rgb(0, 113, 151); +} + +/*tag activity - dark */ +:host-context(body.dark-theme) .tags-activity, +:host-context(body.dark-theme) .tags-activity span { + color: #397af4; +} + .reset-button { background-color: #66bb6a; display: block; diff --git a/src/app/component/sidenav-buttons/sidenav-buttons.component.html b/src/app/component/sidenav-buttons/sidenav-buttons.component.html index 2e40515ab..b499a5a13 100644 --- a/src/app/component/sidenav-buttons/sidenav-buttons.component.html +++ b/src/app/component/sidenav-buttons/sidenav-buttons.component.html @@ -1,4 +1,5 @@ + {{ Options[i] }} + + + + + + + + {{ isNightMode ? 'light_mode' : 'dark_mode' }} + +

+ {{ isNightMode ? 'Switch to Light Mode' : 'Switch to Dark Mode' }} +

+
+
diff --git a/src/app/component/sidenav-buttons/sidenav-buttons.component.spec.ts b/src/app/component/sidenav-buttons/sidenav-buttons.component.spec.ts index 40ce57642..fd22d9bb9 100644 --- a/src/app/component/sidenav-buttons/sidenav-buttons.component.spec.ts +++ b/src/app/component/sidenav-buttons/sidenav-buttons.component.spec.ts @@ -32,7 +32,7 @@ describe('SidenavButtonsComponent', () => { it('check for navigation names being shown in the same order as options array', () => { const HTMLElement: HTMLElement = fixture.nativeElement; - const NavigationList = HTMLElement.querySelectorAll('h3')!; + const NavigationList = HTMLElement.querySelectorAll('a > h3')!; let NavigationNamesBeingShown = []; for (var x = 0; x < NavigationList.length; x += 1) { NavigationNamesBeingShown.push(NavigationList[x].textContent); diff --git a/src/app/component/sidenav-buttons/sidenav-buttons.component.ts b/src/app/component/sidenav-buttons/sidenav-buttons.component.ts index a48f9703d..53073eb35 100644 --- a/src/app/component/sidenav-buttons/sidenav-buttons.component.ts +++ b/src/app/component/sidenav-buttons/sidenav-buttons.component.ts @@ -1,11 +1,12 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; +import { ThemeService } from '../../service/theme.service'; @Component({ selector: 'app-sidenav-buttons', templateUrl: './sidenav-buttons.component.html', styleUrls: ['./sidenav-buttons.component.css'], }) -export class SidenavButtonsComponent { +export class SidenavButtonsComponent implements OnInit { Options: string[] = [ 'Overview', 'Matrix', @@ -33,5 +34,19 @@ export class SidenavButtonsComponent { '/about', '/userday', ]; - constructor() {} + + isNightMode = false; + + constructor(private themeService: ThemeService) {} + + ngOnInit(): void { + const currentTheme = this.themeService.getTheme(); + this.isNightMode = currentTheme === 'dark'; + } + + toggleTheme(): void { + this.isNightMode = !this.isNightMode; + const newTheme = this.isNightMode ? 'dark' : 'light'; + this.themeService.setTheme(newTheme); + } } diff --git a/src/app/component/teams/teams.component.css b/src/app/component/teams/teams.component.css index f233fb6c4..ae7eccd15 100644 --- a/src/app/component/teams/teams.component.css +++ b/src/app/component/teams/teams.component.css @@ -33,6 +33,7 @@ h3 { height: 100px; background-color: #66bb6a; border-radius: 10px; + color: white; margin-right: 20px; display: flex; /* Use flex layout */ justify-content: center; /* Center horizontally */ diff --git a/src/app/service/theme.service.ts b/src/app/service/theme.service.ts new file mode 100644 index 000000000..810b76539 --- /dev/null +++ b/src/app/service/theme.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; + +@Injectable({ providedIn: 'root' }) +export class ThemeService { + private themeSubject = new BehaviorSubject('light'); + public readonly theme$ = this.themeSubject.asObservable(); + + constructor() {} + + initTheme(): void { + const saved = localStorage.getItem('theme') || 'light'; + this.setTheme(saved); + } + + setTheme(theme: string): void { + document.body.classList.remove('light-theme', 'dark-theme'); + document.body.classList.add(`${theme}-theme`); + localStorage.setItem('theme', theme); + this.themeSubject.next(theme); + } + + getTheme(): string { + return this.themeSubject.value; + } +} diff --git a/src/custom-theme.scss b/src/custom-theme.scss index 85ffe50ce..d7d437a97 100644 --- a/src/custom-theme.scss +++ b/src/custom-theme.scss @@ -1,44 +1,188 @@ - -// Custom Theming for Angular Material -// For more information: https://material.angular.io/guide/theming @use '@angular/material' as mat; -// Plus imports for other components in your app. -// Include the common styles for Angular Material. We include this here so that you only -// have to load a single css file for Angular Material in your app. -// Be sure that you only ever include this mixin once! +// ---------------------------------------------- +// Theme Colors and Typography +// ---------------------------------------------- +$light-theme: ( + background: white, + text: black, + link: blue, +); -$custom-typography: mat.define-typography-level( - $font-family: montserrat, - $font-weight: 400, - $font-size: 1rem, - $line-height: 1, - $letter-spacing: normal, +$custom-dark-theme: ( + background: #2c2c2c, + text: #e0e0e0, + link: #bb86fc, ); +$custom-typography: mat.define-typography-level( + $font-family: montserrat, + $font-weight: 400, + $font-size: 1rem, + $line-height: 1, + $letter-spacing: normal +); @include mat.core($custom-typography); -// Define the palettes for your theme using the Material Design palettes available in palette.scss -// (imported above). For each palette, you can optionally specify a default, lighter, and darker -// hue. Available color palettes: https://material.io/design/color/ -$DSOMM-primary: mat.define-palette(mat.$green-palette,400); +// ---------------------------------------------- +// Angular Material Palettes +// ---------------------------------------------- +$DSOMM-primary: mat.define-palette(mat.$green-palette, 400); $DSOMM-accent: mat.define-palette(mat.$pink-palette, A200, A100, A400); - -// The warn palette is optional (defaults to red). $DSOMM-warn: mat.define-palette(mat.$red-palette); -// Create the theme object. A theme consists of configurations for individual -// theming systems such as "color" or "typography". -$DSOMM-theme: mat.define-light-theme(( - color: ( - primary: $DSOMM-primary, - accent: $DSOMM-accent, - warn: $DSOMM-warn, - ) +// ---------------------------------------------- +// Angular Material Themes +// ---------------------------------------------- +$DSOMM-light-theme: mat.define-light-theme(( + color: ( + primary: $DSOMM-primary, + accent: $DSOMM-accent, + warn: $DSOMM-warn + ) +)); + +$DSOMM-dark-theme: mat.define-dark-theme(( + color: ( + primary: $DSOMM-primary, + accent: $DSOMM-accent, + warn: $DSOMM-warn + ) )); -// Include theme styles for core and each component used in your app. -// Alternatively, you can import and @include the theme mixins for each component -// that you are using. -@include mat.all-component-themes($DSOMM-theme); +// ---------------------------------------------- +// Base Theme Mixin +// ---------------------------------------------- +@mixin apply-theme($theme) { + background-color: map-get($theme, background); + color: map-get($theme, text); + + a { + color: map-get($theme, link); + } + a:visited { + color: map-get($theme, visited-link); + } +} + +// ---------------------------------------------- +// Light Mode Styles +// ---------------------------------------------- +body { + + .title-button, + h1, h2, h3, h4, h5, h6 { + color: map-get($light-theme, text); + } +} + +.light-theme { + --heatmap-filled: #4caf50; + --heatmap-disabled: #dddddd; + --heatmap-cursor: green; + --heatmap-background: white; + --heatmap-stroke: black; + --heatmap-cursor-selected:var(--heatmap-cursor); + --heatmap-cursor-hover: transparent; + + @include mat.all-component-themes($DSOMM-light-theme); +} + +// ---------------------------------------------- +// Dark Mode Styles +// ---------------------------------------------- +body.dark-theme { + @include apply-theme($custom-dark-theme); + @include mat.all-component-themes($DSOMM-dark-theme); + + --heatmap-filled: #007700; + --heatmap-disabled: #666666; + --heatmap-cursor: green; + --heatmap-background: #bbbbbb; + --heatmap-stroke: #000000; + --heatmap-cursor-selected:var(--heatmap-cursor); + --heatmap-cursor-hover: transparent; + + .title-button, + h1, h2, h3, h4, h5, h6 { + color: map-get($custom-dark-theme, text); + } + + // General properties + p, li, tr { + color: #e0e0e0; + } + + // Common containers + mat-card, + .mat-dialog-container, + .mat-expansion-panel, + .mat-accordion, + .overlay-wrapper { + background-color: #2c2c2c; + color: #e0e0e0; + } + + // Dialog styling + .mat-dialog-container { + border: 1px solid #444; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.7); + } + + // Modal override + .overlay-modal { + background-color: #2c2c2c; + color: #e0e0e0; + border-radius: 6px; + + mat-card { + background-color: transparent; + } + + h1, h2, h3, h4, h5, h6 { + color: #e0e0e0; + } + } + + // Circular heatmap (radar chart) + .circular-heat text, + .labels.segment text { + fill: #ffffff; + } + + .circular-heat line, + .circular-heat path { + stroke: var(--heatmap-stroke); + } + + .mat-chip.mat-standard-chip { + color: #ababab; + } + + .mat-chip.mat-standard-chip.mat-chip-selected.mat-primary { + background-color: #74b277; + } +} + +@include mat.all-component-themes($DSOMM-dark-theme); + +.button-container { + display: flex; + flex-direction: column; // Vertical alignment + gap: 10px; // Space between buttons +} + +svg .cursors path { + fill: transparent; + pointer-events: none; +} + +svg .cursors #hover { + stroke: var(--heatmap-cursor); + stroke-width: 7px; +} +svg .cursors #selected { + stroke: var(--heatmap-cursor-selected, #000000); // optional fallback + stroke-width: 7px; +} diff --git a/src/index.html b/src/index.html index 4d624b57e..8d114a99a 100644 --- a/src/index.html +++ b/src/index.html @@ -17,7 +17,7 @@ rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-fork-ribbon-css/0.2.3/gh-fork-ribbon.min.css" /> - + diff --git a/src/main.ts b/src/main.ts index fa4e0aef3..345453bdf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,8 @@ +const savedTheme = localStorage.getItem('theme') || 'light'; +document.body.classList.remove('light-theme', 'dark-theme'); +document.body.classList.add(`${savedTheme}-theme`); +console.log('[main.ts] Theme set to:', savedTheme); // + import { enableProdMode } from '@angular/core'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; diff --git a/src/styles.css b/src/styles.css index 3f9148dbb..e1b4d99e7 100644 --- a/src/styles.css +++ b/src/styles.css @@ -8,6 +8,13 @@ body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } padding: 0.3em; } +.dark-theme .userday table :is(td, th) { + border-color: #e0e0e0; +} +.dark-theme .userday tr:nth-child(even) { + background-color: #365d36; +} + .userday tr:nth-child(even) { background-color: #66bb6a; }