Skip to content

dynamic_competence_map API Documentation

dcm_assessment

Created on 2024-01-10

@author: wf

Assessment

Assessment for CompetenceTree

Source code in dcm/dcm_assessment.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
class Assessment:
    """
    Assessment for CompetenceTree
    """

    def __init__(
        self,
        webserver: NiceGuiWebserver,
        dcm: DynamicCompetenceMap,
        learner: Learner,
        debug: bool = False,
    ):
        """
        initialize the assessment

        Args:
            webserver(NiceguiWebServer): the webserver context
            dcm(DynamicCompetenceMap): the competence map
            learner(Learner): the learner to get the self assessment for
            debug(bool): if True show debugging information
        """
        self.webserver = webserver
        self.debug = debug
        self.reset(dcm=dcm, learner=learner)
        self.setup_ui()

    def store(self) -> str:
        """
        Store the current state of
        the learner's achievements.

        Returns(str): the path to the file
        """
        file_path = None
        try:
            # Serialize the learner object to JSON
            learner_data_json = self.learner.to_json(indent=2)

            # Determine the file path for storing the learner's data
            filename = self.learner.file_name + ".json"
            file_path = os.path.join(self.webserver.config.storage_path, filename)

            # Write the serialized data to the file
            with open(file_path, "w") as file:
                file.write(learner_data_json)

            if self.debug:
                print(f"Learner data stored in {file_path}")

        except Exception as ex:
            self.webserver.handle_exception(ex)
        return file_path

    def reset(
        self,
        dcm: DynamicCompetenceMap,
        learner: Learner,
    ):
        """
            (re)set the assessment

        Args:
            webserver(NiceguiWebServer): the webserver context
            dcm(DynamicCompetenceMap): the competence map
            learner(Learner): the learner to get the self assessment for
        """
        self.dcm = dcm
        self.competence_tree = dcm.competence_tree
        self.learner = learner
        self.achievement_index = 0
        # do we need setup the achievements?
        if self.learner.achievements is None:
            self.learner.achievements = []
            self.setup_achievements()
        self.total = len(self.learner.achievements)

    def clear(self):
        """
        clear the ui
        """
        self.container.clear()

    @property
    def current_achievement(self) -> Achievement:
        if self.achievement_index < 0 or self.achievement_index > len(
            self.learner.achievements
        ):
            raise ValueError(f"invalid achievement index {self.achievement_index}")
        achievement = self.learner.achievements[self.achievement_index]
        return achievement

    def setup_achievements(self):
        """
        Setup achievements based on the competence tree.

        This method iterates over the competence aspects and their facets,
        constructs a path for each facet, and creates an Achievement instance
        based on the path. These achievements are then added to the learner's
        achievements list.
        """
        for aspect in self.competence_tree.aspects:
            for area in aspect.areas:
                self.add_achievement(area.path)
                for facet in area.facets:
                    # Construct the path for the facet
                    self.add_achievement(facet.path)

    def add_achievement(self, path):
        # Create a new Achievement instance with the constructed path
        new_achievement = Achievement(
            path=path,
        )
        self.learner.add_achievement(new_achievement)

    def get_index_str(self) -> str:
        """
        get the current achievement index
        """
        index_str = f"{self.achievement_index+1:2}/{self.total:2}"
        return index_str

    def setup_ui(self):
        """
        display my competence Tree elements
        """
        with ui.grid(columns=1).classes("w-full") as self.container:
            self.progress_bar = NiceguiProgressbar(
                total=self.total, desc="self assessment", unit="facets"
            )
            self.progress_bar.reset()
            facet_element_name = (
                self.competence_tree.element_names.get("facet") or "facet"
            )
            area_element_name = self.competence_tree.element_names.get("area") or "area"

            with ui.row() as self.navigation_row:
                ui.button(
                    "", icon="first_page", on_click=lambda _args: self.goto(0)
                ).tooltip("to first")
                ui.button(
                    "", icon="fast_rewind", on_click=lambda _args: self.step_area(-1)
                ).tooltip(f"previous {area_element_name}")
                ui.button(
                    "", icon="arrow_back", on_click=lambda _args: self.step(-1)
                ).tooltip(f"previous {facet_element_name}")
                ui.button(
                    "", icon="arrow_forward", on_click=lambda _args: self.step(1)
                ).tooltip(f"next {facet_element_name}")
                ui.button(
                    "", icon="fast_forward", on_click=lambda _args: self.step_area(1)
                ).tooltip(f"next {area_element_name}")
                ui.button(
                    "",
                    icon="last_page",
                    on_click=lambda _args: self.goto(self.total - 1),
                ).tooltip("to last")
            with ui.row() as self.button_row_row:
                self.button_row = ButtonRow(
                    self, self.competence_tree, self.current_achievement
                )
            with ui.row() as self.card_row:
                with ui.card() as self.achievement_view:
                    self.index_view = ui.label(self.get_index_str())
                    self.link_view = ui.html()
                    self.markdown_view = ui.markdown()

    def show_progress(self):
        """
        Update the progress bar based on the
        number of achievements with a non-None level value.
        """
        count = sum(
            1
            for achievement in self.learner.achievements
            if achievement.level is not None
        )
        self.progress_bar.total = self.total
        self.progress_bar.update_value(count)

    async def step_area(self, area_step: int):
        """
        Step towards the area given in area_step.
        For example, 1 means next area, -1 means previous area.
        """
        if area_step == 0:
            return  # No movement required

        direction = 1 if area_step > 0 else -1
        area_count = 0

        # Start with the next/previous achievement based on the direction
        new_index = self.achievement_index + direction

        # Loop through achievements until the desired area count is reached
        while 0 <= new_index < len(self.learner.achievements):
            achievement = self.learner.achievements[new_index]
            element = self.competence_tree.lookup_by_path(achievement.path)

            if isinstance(element, CompetenceArea):
                area_count += 1
                if area_count == abs(area_step):
                    # Found the required area, update the index
                    self.goto(new_index)
                    return

            # Move to the next/previous achievement
            new_index += direction

        # Notify if no more areas in the direction
        with self.container:
            ui.notify("Reached the end of the areas in this direction.")

    def goto(self, index: int):
        self.achievement_index = index
        self.step(0)

    def step(self, step: int = 0):
        """
        step with the achievement view
        """
        self.update_achievement_view(step)

    def update_achievement_view(self, step: int = 0):
        """
        display the active achievement as the step indicates
        """
        self.show_progress()
        if self.achievement_index + step < 0:
            ui.notify("first achievement reached!")
            step = 0
        if self.achievement_index + step < len(self.learner.achievements):
            self.achievement_index += step
        else:
            ui.notify("Done!")
        self.update_current_achievement_view()

    def update_current_achievement_view(self):
        """
        show the current achievement
        """
        self.index_view.text = self.get_index_str()
        achievement = self.current_achievement
        self.store()
        self.webserver.render_dcm(
            self.dcm,
            self.learner,
            selected_paths=[achievement.path],
            clear_assessment=False,
        )
        self.button_row.achievement = achievement
        self.button_row.set_button_states(achievement)
        competence_element = self.competence_tree.lookup_by_path(achievement.path)
        if not competence_element:
            ui.notify(f"invalid path: {achievement.path}")
            self.markdown_view.content = f"⚠️ {achievement.path}"
        else:
            if hasattr(competence_element, "path"):
                if competence_element.url:
                    link = Link.create(competence_element.url, competence_element.path)
                else:
                    link = competence_element.path
            else:
                link = "⚠️ - competence element path missing"
            self.link_view.content = link
            description = competence_element.description or ""
            if isinstance(competence_element, CompetenceArea):
                aspect = competence_element.aspect
                description = f"### {aspect.name}\n\n**{competence_element.name}**:\n\n{description}"
            if isinstance(competence_element, CompetenceFacet):
                area = competence_element.area
                description = f"### {area.name}\n\n**{competence_element.name}**:\n\n{description}"
            self.markdown_view.content = description

__init__(webserver, dcm, learner, debug=False)

initialize the assessment

Parameters:

Name Type Description Default
webserver(NiceguiWebServer)

the webserver context

required
dcm(DynamicCompetenceMap)

the competence map

required
learner(Learner)

the learner to get the self assessment for

required
debug(bool)

if True show debugging information

required
Source code in dcm/dcm_assessment.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def __init__(
    self,
    webserver: NiceGuiWebserver,
    dcm: DynamicCompetenceMap,
    learner: Learner,
    debug: bool = False,
):
    """
    initialize the assessment

    Args:
        webserver(NiceguiWebServer): the webserver context
        dcm(DynamicCompetenceMap): the competence map
        learner(Learner): the learner to get the self assessment for
        debug(bool): if True show debugging information
    """
    self.webserver = webserver
    self.debug = debug
    self.reset(dcm=dcm, learner=learner)
    self.setup_ui()

clear()

clear the ui

Source code in dcm/dcm_assessment.py
198
199
200
201
202
def clear(self):
    """
    clear the ui
    """
    self.container.clear()

get_index_str()

get the current achievement index

Source code in dcm/dcm_assessment.py
236
237
238
239
240
241
def get_index_str(self) -> str:
    """
    get the current achievement index
    """
    index_str = f"{self.achievement_index+1:2}/{self.total:2}"
    return index_str

reset(dcm, learner)

(re)set the assessment

Parameters:

Name Type Description Default
webserver(NiceguiWebServer)

the webserver context

required
dcm(DynamicCompetenceMap)

the competence map

required
learner(Learner)

the learner to get the self assessment for

required
Source code in dcm/dcm_assessment.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
def reset(
    self,
    dcm: DynamicCompetenceMap,
    learner: Learner,
):
    """
        (re)set the assessment

    Args:
        webserver(NiceguiWebServer): the webserver context
        dcm(DynamicCompetenceMap): the competence map
        learner(Learner): the learner to get the self assessment for
    """
    self.dcm = dcm
    self.competence_tree = dcm.competence_tree
    self.learner = learner
    self.achievement_index = 0
    # do we need setup the achievements?
    if self.learner.achievements is None:
        self.learner.achievements = []
        self.setup_achievements()
    self.total = len(self.learner.achievements)

setup_achievements()

Setup achievements based on the competence tree.

This method iterates over the competence aspects and their facets, constructs a path for each facet, and creates an Achievement instance based on the path. These achievements are then added to the learner's achievements list.

Source code in dcm/dcm_assessment.py
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def setup_achievements(self):
    """
    Setup achievements based on the competence tree.

    This method iterates over the competence aspects and their facets,
    constructs a path for each facet, and creates an Achievement instance
    based on the path. These achievements are then added to the learner's
    achievements list.
    """
    for aspect in self.competence_tree.aspects:
        for area in aspect.areas:
            self.add_achievement(area.path)
            for facet in area.facets:
                # Construct the path for the facet
                self.add_achievement(facet.path)

setup_ui()

display my competence Tree elements

Source code in dcm/dcm_assessment.py
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def setup_ui(self):
    """
    display my competence Tree elements
    """
    with ui.grid(columns=1).classes("w-full") as self.container:
        self.progress_bar = NiceguiProgressbar(
            total=self.total, desc="self assessment", unit="facets"
        )
        self.progress_bar.reset()
        facet_element_name = (
            self.competence_tree.element_names.get("facet") or "facet"
        )
        area_element_name = self.competence_tree.element_names.get("area") or "area"

        with ui.row() as self.navigation_row:
            ui.button(
                "", icon="first_page", on_click=lambda _args: self.goto(0)
            ).tooltip("to first")
            ui.button(
                "", icon="fast_rewind", on_click=lambda _args: self.step_area(-1)
            ).tooltip(f"previous {area_element_name}")
            ui.button(
                "", icon="arrow_back", on_click=lambda _args: self.step(-1)
            ).tooltip(f"previous {facet_element_name}")
            ui.button(
                "", icon="arrow_forward", on_click=lambda _args: self.step(1)
            ).tooltip(f"next {facet_element_name}")
            ui.button(
                "", icon="fast_forward", on_click=lambda _args: self.step_area(1)
            ).tooltip(f"next {area_element_name}")
            ui.button(
                "",
                icon="last_page",
                on_click=lambda _args: self.goto(self.total - 1),
            ).tooltip("to last")
        with ui.row() as self.button_row_row:
            self.button_row = ButtonRow(
                self, self.competence_tree, self.current_achievement
            )
        with ui.row() as self.card_row:
            with ui.card() as self.achievement_view:
                self.index_view = ui.label(self.get_index_str())
                self.link_view = ui.html()
                self.markdown_view = ui.markdown()

show_progress()

Update the progress bar based on the number of achievements with a non-None level value.

Source code in dcm/dcm_assessment.py
288
289
290
291
292
293
294
295
296
297
298
299
def show_progress(self):
    """
    Update the progress bar based on the
    number of achievements with a non-None level value.
    """
    count = sum(
        1
        for achievement in self.learner.achievements
        if achievement.level is not None
    )
    self.progress_bar.total = self.total
    self.progress_bar.update_value(count)

step(step=0)

step with the achievement view

Source code in dcm/dcm_assessment.py
338
339
340
341
342
def step(self, step: int = 0):
    """
    step with the achievement view
    """
    self.update_achievement_view(step)

step_area(area_step) async

Step towards the area given in area_step. For example, 1 means next area, -1 means previous area.

Source code in dcm/dcm_assessment.py
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
async def step_area(self, area_step: int):
    """
    Step towards the area given in area_step.
    For example, 1 means next area, -1 means previous area.
    """
    if area_step == 0:
        return  # No movement required

    direction = 1 if area_step > 0 else -1
    area_count = 0

    # Start with the next/previous achievement based on the direction
    new_index = self.achievement_index + direction

    # Loop through achievements until the desired area count is reached
    while 0 <= new_index < len(self.learner.achievements):
        achievement = self.learner.achievements[new_index]
        element = self.competence_tree.lookup_by_path(achievement.path)

        if isinstance(element, CompetenceArea):
            area_count += 1
            if area_count == abs(area_step):
                # Found the required area, update the index
                self.goto(new_index)
                return

        # Move to the next/previous achievement
        new_index += direction

    # Notify if no more areas in the direction
    with self.container:
        ui.notify("Reached the end of the areas in this direction.")

store()

Store the current state of the learner's achievements.

Returns(str): the path to the file

Source code in dcm/dcm_assessment.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def store(self) -> str:
    """
    Store the current state of
    the learner's achievements.

    Returns(str): the path to the file
    """
    file_path = None
    try:
        # Serialize the learner object to JSON
        learner_data_json = self.learner.to_json(indent=2)

        # Determine the file path for storing the learner's data
        filename = self.learner.file_name + ".json"
        file_path = os.path.join(self.webserver.config.storage_path, filename)

        # Write the serialized data to the file
        with open(file_path, "w") as file:
            file.write(learner_data_json)

        if self.debug:
            print(f"Learner data stored in {file_path}")

    except Exception as ex:
        self.webserver.handle_exception(ex)
    return file_path

update_achievement_view(step=0)

display the active achievement as the step indicates

Source code in dcm/dcm_assessment.py
344
345
346
347
348
349
350
351
352
353
354
355
356
def update_achievement_view(self, step: int = 0):
    """
    display the active achievement as the step indicates
    """
    self.show_progress()
    if self.achievement_index + step < 0:
        ui.notify("first achievement reached!")
        step = 0
    if self.achievement_index + step < len(self.learner.achievements):
        self.achievement_index += step
    else:
        ui.notify("Done!")
    self.update_current_achievement_view()

update_current_achievement_view()

show the current achievement

Source code in dcm/dcm_assessment.py
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
def update_current_achievement_view(self):
    """
    show the current achievement
    """
    self.index_view.text = self.get_index_str()
    achievement = self.current_achievement
    self.store()
    self.webserver.render_dcm(
        self.dcm,
        self.learner,
        selected_paths=[achievement.path],
        clear_assessment=False,
    )
    self.button_row.achievement = achievement
    self.button_row.set_button_states(achievement)
    competence_element = self.competence_tree.lookup_by_path(achievement.path)
    if not competence_element:
        ui.notify(f"invalid path: {achievement.path}")
        self.markdown_view.content = f"⚠️ {achievement.path}"
    else:
        if hasattr(competence_element, "path"):
            if competence_element.url:
                link = Link.create(competence_element.url, competence_element.path)
            else:
                link = competence_element.path
        else:
            link = "⚠️ - competence element path missing"
        self.link_view.content = link
        description = competence_element.description or ""
        if isinstance(competence_element, CompetenceArea):
            aspect = competence_element.aspect
            description = f"### {aspect.name}\n\n**{competence_element.name}**:\n\n{description}"
        if isinstance(competence_element, CompetenceFacet):
            area = competence_element.area
            description = f"### {area.name}\n\n**{competence_element.name}**:\n\n{description}"
        self.markdown_view.content = description

ButtonRow

A button row for selecting competence levels to document achievements from a CompetenceTree.

Source code in dcm/dcm_assessment.py
 25
 26
 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
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
class ButtonRow:
    """
    A button row for selecting competence levels
    to document achievements from  a CompetenceTree.
    """

    def __init__(
        self,
        assessment: "Assessment",
        competence_tree: CompetenceTree,
        achievement: Achievement = None,
    ):
        """
        Construct a button row for the competence levels of the given CompetenceTree.

        Args:
            assessment (Assessment): The Assessment instance.
            competence_tree (CompetenceTree): The Competence Tree to display buttons for.
            achievement (Achievement): The current achievement of the learner.
        """
        self.assessment = assessment
        self.competence_tree = competence_tree
        self.achievement = achievement
        self.setup_buttons()
        self.set_button_states(achievement)

    def setup_buttons(self):
        """
        Create buttons for each competence level defined in the CompetenceTree.
        """
        self.buttons = {}
        with ui.row() as self.row:
            for level in self.competence_tree.levels:
                # generate a button to represent the level
                button_label = level.name
                if level.utf8_icon:
                    button_label = f"{level.utf8_icon} {level.name}"
                button = ui.button(
                    button_label,
                    icon=level.icon,
                    color=level.color_code,
                    on_click=lambda _args, l=level.level: self.handle_selection(l),
                ).tooltip(level.description)
                self.buttons[level.level] = button

    def set_button_states(self, achievement: Achievement):
        """
        Set the state of buttons based on the given achievement.

        Args:
            achievement (Achievement): The current achievement of the learner.
        """
        # If no achievement or level is set, enable all buttons
        if achievement is None or achievement.level is None:
            for button in self.buttons.values():
                button.enable()
                button.visible = True
        else:
            # Enable only the button corresponding to the current level and disable others
            for level, button in self.buttons.items():
                if level == achievement.level:
                    button.enable()
                    button.visible = True
                else:
                    button.disable()
                    button.visible = False

    def handle_selection(self, selected_level: int):
        """
        handle the selected level

        Args:
            selected_level(int): the selected level
        """
        # Check if the same level is selected again,
        # then reset the selection
        if self.achievement.level == selected_level:
            self.achievement.level = None
        else:
            self.achievement.level = selected_level
            # Get the current time in UTC
            current_time_utc = datetime.now(timezone.utc)

            # Convert the current time to ISO format
            date_assessed_iso = current_time_utc.isoformat()

            # Assign it to self.achievement.date_assessed_iso
            self.achievement.date_assessed_iso = date_assessed_iso

        self.set_button_states(self.achievement)
        # refresh the ui
        self.row.update()
        # show achievement_view
        step = 1 if self.achievement.level else 0
        self.assessment.step(step)

__init__(assessment, competence_tree, achievement=None)

Construct a button row for the competence levels of the given CompetenceTree.

Parameters:

Name Type Description Default
assessment Assessment

The Assessment instance.

required
competence_tree CompetenceTree

The Competence Tree to display buttons for.

required
achievement Achievement

The current achievement of the learner.

None
Source code in dcm/dcm_assessment.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def __init__(
    self,
    assessment: "Assessment",
    competence_tree: CompetenceTree,
    achievement: Achievement = None,
):
    """
    Construct a button row for the competence levels of the given CompetenceTree.

    Args:
        assessment (Assessment): The Assessment instance.
        competence_tree (CompetenceTree): The Competence Tree to display buttons for.
        achievement (Achievement): The current achievement of the learner.
    """
    self.assessment = assessment
    self.competence_tree = competence_tree
    self.achievement = achievement
    self.setup_buttons()
    self.set_button_states(achievement)

handle_selection(selected_level)

handle the selected level

Parameters:

Name Type Description Default
selected_level(int)

the selected level

required
Source code in dcm/dcm_assessment.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def handle_selection(self, selected_level: int):
    """
    handle the selected level

    Args:
        selected_level(int): the selected level
    """
    # Check if the same level is selected again,
    # then reset the selection
    if self.achievement.level == selected_level:
        self.achievement.level = None
    else:
        self.achievement.level = selected_level
        # Get the current time in UTC
        current_time_utc = datetime.now(timezone.utc)

        # Convert the current time to ISO format
        date_assessed_iso = current_time_utc.isoformat()

        # Assign it to self.achievement.date_assessed_iso
        self.achievement.date_assessed_iso = date_assessed_iso

    self.set_button_states(self.achievement)
    # refresh the ui
    self.row.update()
    # show achievement_view
    step = 1 if self.achievement.level else 0
    self.assessment.step(step)

set_button_states(achievement)

Set the state of buttons based on the given achievement.

Parameters:

Name Type Description Default
achievement Achievement

The current achievement of the learner.

required
Source code in dcm/dcm_assessment.py
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def set_button_states(self, achievement: Achievement):
    """
    Set the state of buttons based on the given achievement.

    Args:
        achievement (Achievement): The current achievement of the learner.
    """
    # If no achievement or level is set, enable all buttons
    if achievement is None or achievement.level is None:
        for button in self.buttons.values():
            button.enable()
            button.visible = True
    else:
        # Enable only the button corresponding to the current level and disable others
        for level, button in self.buttons.items():
            if level == achievement.level:
                button.enable()
                button.visible = True
            else:
                button.disable()
                button.visible = False

setup_buttons()

Create buttons for each competence level defined in the CompetenceTree.

Source code in dcm/dcm_assessment.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def setup_buttons(self):
    """
    Create buttons for each competence level defined in the CompetenceTree.
    """
    self.buttons = {}
    with ui.row() as self.row:
        for level in self.competence_tree.levels:
            # generate a button to represent the level
            button_label = level.name
            if level.utf8_icon:
                button_label = f"{level.utf8_icon} {level.name}"
            button = ui.button(
                button_label,
                icon=level.icon,
                color=level.color_code,
                on_click=lambda _args, l=level.level: self.handle_selection(l),
            ).tooltip(level.description)
            self.buttons[level.level] = button

dcm_chart

Created on 2024-01-12

@author: wf

DcmChart

a Dynamic competence map chart

Source code in dcm/dcm_chart.py
 21
 22
 23
 24
 25
 26
 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
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
class DcmChart:
    """
    a Dynamic competence map chart
    """

    def __init__(self, dcm: DynamicCompetenceMap):
        """
        Constructor
        """
        self.dcm = dcm

    def prepare_and_add_inner_circle(
        self, config, competence_tree: CompetenceTree, lookup_url: str = None
    ):
        """
        prepare the SVG markup generation and add
        the inner_circle
        """
        self.lookup_url = (
            competence_tree.lookup_url if competence_tree.lookup_url else lookup_url
        )

        svg = SVG(config)
        self.svg = svg
        config = svg.config
        # center of circle
        self.cx = config.width // 2
        self.cy = (config.total_height - config.legend_height) // 2
        ringspec = competence_tree.ring_specs.get("tree")
        self.tree_radius = ringspec.outer_ratio * config.width / 2

        self.circle_config = competence_tree.to_svg_node_config(
            x=self.cx, y=self.cy, width=self.tree_radius
        )
        svg.add_circle(config=self.circle_config)
        if ringspec.text_mode != "empty":
            svg.add_text(
                self.cx,
                self.cy,
                competence_tree.short_name,
                text_anchor="middle",
                center_v=True,
                fill="white",
            )
        return svg

    def generate_svg(
        self,
        filename: Optional[str] = None,
        learner: Optional[Learner] = None,
        config: Optional[SVGConfig] = None,
        text_mode: str = "empty",
    ) -> str:
        """
        Generate the SVG markup and optionally save it to a file. If a filename is given, the method
        will also save the SVG to that file. The SVG is generated based on internal state not shown here.

        Args:
            filename (str, optional): The path to the file where the SVG should be saved. Defaults to None.
            learner(Learner): the learner to show the achievements for
            config (SVGConfig, optional): The configuration for the SVG canvas and legend. Defaults to default values.
            text_mode(str): text display mode
        Returns:
            str: The SVG markup.
        """
        if config is None:
            config = SVGConfig(
                with_popup=True
            )  # Use default configuration if none provided
        svg_markup = self.generate_svg_markup(
            self.dcm.competence_tree,
            learner=learner,
            config=config,
            text_mode=text_mode,
        )
        if filename:
            self.save_svg_to_file(svg_markup, filename)
        return svg_markup

    def get_element_config(self, element: CompetenceElement) -> SVGNodeConfig:
        """
        get a configuration for the given element

        Args:
            element(CompetenceElement): the element

        Return:
            SVGNodeConfig: an SVG Node configuration
        """
        if element is None:
            element_config = SVGNodeConfig(x=self.cx, y=self.cy, fill="white")
            return element_config
        element_url = (
            element.url
            if element.url
            else (
                f"{self.lookup_url}/description/{element.path}"
                if self.lookup_url is not None
                else None
            )
        )
        show_as_popup = element.url is None
        element_config = element.to_svg_node_config(
            url=element_url,
            show_as_popup=show_as_popup,
            x=self.cx,
            y=self.cy,
        )
        return element_config

    def get_stacked_segment(
        self,
        level: int,
        total_levels: int,
        segment: DonutSegment,
        element_config: SVGNodeConfig,
    ) -> Tuple[DonutSegment, SVGNodeConfig]:
        """
        Calculate the stacked segment for a given level.

        Args:
            level (int): The current level for which to calculate the segment.
            total_levels (int): The total number of levels.
            segment (DonutSegment): The original donut Segment.
            element_config (SVGNodeConfig): The element configuration.

        Returns:
            Tuple[DonutSegment, SVGNodeConfig]:
                The calculated stacked segment and its configuration for the given level.

        """
        level_color = self.dcm.competence_tree.get_level_color(level)
        stack_element_config = copy.deepcopy(element_config)
        stack_element_config.fill = level_color
        ratio = level / total_levels
        relative_radius = (segment.outer_radius - segment.inner_radius) * ratio
        stacked_segment = copy.deepcopy(segment)
        stacked_segment.outer_radius = segment.inner_radius + relative_radius
        return stacked_segment, stack_element_config

    def add_donut_segment(
        self,
        svg: SVG,
        element: CompetenceElement,
        segment: DonutSegment,
        level_color=None,
        achievement_level=None,
        ringspec: RingSpec = None,
    ) -> DonutSegment:
        """
        create a donut segment for the
        given competence element and add it
        to the given SVG

        if level color is available an achievement
        needs to be potentially shown
        """
        element_config = self.get_element_config(element)
        # make sure we show the text on the original segment
        text_segment = copy.deepcopy(segment)

        if level_color:
            element_config.fill = level_color  # Set the color
        if element and element.path in self.selected_paths:
            element_config.element_class = "selected"
            element_config.color = "blue"
        # in case we need to draw an achievement
        total_levels = self.dcm.competence_tree.total_valid_levels

        if achievement_level is None:
            result = svg.add_donut_segment(config=element_config, segment=segment)
        else:
            if not self.dcm.competence_tree.stacked_levels:
                ratio = achievement_level / total_levels
                relative_radius = (segment.outer_radius - segment.inner_radius) * ratio
                segment.outer_radius = segment.inner_radius + relative_radius
                result = svg.add_donut_segment(config=element_config, segment=segment)
            else:
                # create the stacked segments starting with the highest level
                for level in range(achievement_level, 0, -1):
                    stacked_segment, stack_element_config = self.get_stacked_segment(
                        level, total_levels, segment, element_config
                    )
                    # the result will be overriden in the loop so we'll return the innermost
                    result = svg.add_donut_segment(
                        config=stack_element_config, segment=stacked_segment
                    )
        if element:
            text_mode = "empty"
            if segment.text_mode:
                text_mode = segment.text_mode
            if text_mode != "empty":
                # no autofill please
                # textwrap.fill(element.short_name, width=20)
                text = element.short_name
                self.svg.add_text_to_donut_segment(
                    text_segment, text, direction=text_mode
                )
        # in stacked mode show the level circles
        # by drawing arcs even if no achievements
        # are available
        if ringspec and ringspec.levels_visible:
            if total_levels:
                svg.add_element(f"<!-- arcs for {element.path} -->")
                for level in range(total_levels):
                    stacked_segment, stack_element_config = self.get_stacked_segment(
                        level, total_levels, segment, element_config
                    )
                    fill = "white"  # Set the color for unachieved levels
                    donut_path = svg.get_donut_path(
                        stacked_segment, radial_offset=1, middle_arc=True
                    )  # Get the path for the stacked segment
                    svg.add_element(
                        f'<path d="{donut_path}" stroke="{fill}" fill="none" stroke-width="1.5" />'
                    )  # Draw the path in SVG

        return result

    def generate_donut_segment_for_achievement(
        self,
        svg: SVG,
        learner: Learner,
        element: CompetenceElement,
        segment: DonutSegment,
    ) -> DonutSegment:
        """
        generate a donut segment for the
        learner's achievements
        corresponding to the given path and return it's segment definition
        """
        achievement = learner.achievements_by_path.get(element.path, None)
        result = None
        if achievement and achievement.level:
            # Retrieve the color for the achievement level
            level_color = self.dcm.competence_tree.get_level_color(achievement.level)

            if level_color:
                # set the color and radius of
                # the segment for achievement
                # make sure we don't interfere with the segment calculations
                segment = copy.deepcopy(segment)
                result = self.add_donut_segment(
                    svg, element, segment, level_color, achievement.level
                )
        return result

    def generate_donut_segment_for_element(
        self,
        svg: SVG,
        element: CompetenceElement,
        learner: Learner,
        segment: DonutSegment,
        ringspec: RingSpec = None,
    ) -> DonutSegment:
        """
        generate a donut segment for a given element of
        the CompetenceTree
        """
        if segment.outer_radius == 0.0:
            result = segment
        else:
            # Simply create the donut segment without considering the achievement
            result = self.add_donut_segment(
                svg=svg, element=element, segment=segment, ringspec=ringspec
            )
            # check learner achievements
            if learner:
                _learner_segment = self.generate_donut_segment_for_achievement(
                    svg=svg, learner=learner, element=element, segment=segment
                )
        return result

    def generate_pie_elements_for_segments(
        self,
        svg: SVG,
        ct: CompetenceTree,
        segments: Dict[str, Dict[str, DonutSegment]],
        learner: Learner,
    ):
        """
        Generate pie elements for the competence tree using pre-calculated segments.

        This method will iterate through the provided segments dictionary, using each pre-calculated
        DonutSegment to generate and render pie elements (e.g., aspects, areas, or facets) based on
        the learner's achievements.

        Args:
            svg (SVG): The SVG object where the pie elements will be drawn.
            ct (CompetenceTree): The competence tree structure.
            segments (Dict[str, Dict[str, DonutSegment]]): A nested dictionary where the first key is the
                                                           level name (e.g., 'aspect', 'area', 'facet'),
                                                           and the second key is an element's path, mapping
                                                           to its corresponding DonutSegment.
            learner (Learner): The learner object containing achievement data.
        """
        for level_name, segment_dict in segments.items():
            ringspec = ct.ring_specs[level_name]
            for path, segment in segment_dict.items():
                element = ct.elements_by_path.get(path, None)
                if element:
                    self.generate_donut_segment_for_element(
                        svg, element, learner, segment=segment, ringspec=ringspec
                    )

    def create_donut_segment(
        self,
        parent_segment: DonutSegment,
        start_angle: float,
        end_angle: float,
        ringspec: RingSpec,
    ) -> DonutSegment:
        """
        Creates a new DonutSegment based on the specified parameters, calculating its
        inner and outer radii based on SVG configuration and ring specifications.

        Args:
            parent_segment (DonutSegment): The parent segment from which the new segment inherits its center (cx, cy).
            start_angle (float): The starting angle of the new segment.
            end_angle (float): The ending angle of the new segment.
            ringspec (RingSpec): An instance of RingSpec defining the ratios for inner and outer radii, and the text mode.

        Returns:
            DonutSegment: A new DonutSegment instance configured as specified.
        """
        # Calculate the actual inner and outer radii based on the SVG config and ringspec ratios
        inner_radius = self.svg.config.width / 2 * ringspec.inner_ratio
        outer_radius = self.svg.config.width / 2 * ringspec.outer_ratio
        # Create a new segment for this element
        segment = DonutSegment(
            cx=parent_segment.cx,
            cy=parent_segment.cy,
            inner_radius=inner_radius,
            outer_radius=outer_radius,
            start_angle=start_angle,
            end_angle=end_angle,
            text_mode=ringspec.text_mode,
        )
        return segment

    def calculate_sub_segments(
        self,
        ct: CompetenceTree,
        parent_segment: DonutSegment,
        level_name: str,
        symmetry_mode: str,
        elements: List[CompetenceElement],
        lenient: bool = True,
    ) -> Dict[str, DonutSegment]:
        """
        Calculates and returns a dictionary of DonutSegment objects for a given level in the Competence Tree.

        This method divides a parent segment into sub-segments based on the number of elements in the specified level,
        and assigns each sub-segment to the corresponding element's path.

        Args:
            ct: An instance of CompetenceTree representing the entire competence structure.
            parent_segment: A DonutSegment instance representing the parent segment within which the sub-segments will be calculated.
            level_name: The name of the level (e.g., 'aspect', 'area', 'facet') for which sub-segments are being calculated.
            symmetry_mode: The symmetry mode ('symmetric' or 'asymmetric') affecting segment calculation.
            elements: A list of CompetenceElement instances at the current level.
            lenient (bool): if True symmetry mode will be adjusted to count in case there are no values
        Returns:
            A dictionary where keys are element paths and values are DonutSegment instances representing each element's segment in the visualization.
        """
        ringspec: RingSpec = ct.ring_specs[level_name]
        sub_segments: Dict[str, DonutSegment] = {}
        attr_names = {"time": "time", "score": "max_score"}
        if len(elements) == 0:
            return sub_segments
        num_zero_none_values = 0

        if symmetry_mode == "count":
            total = len(elements)
        else:
            attr_name = attr_names[symmetry_mode]
            total = 0
            min_value = float(
                "inf"
            )  # Initialize to infinity for proper minimum comparison

            # Initial loop to calculate total and count 0/None values
            for element in elements:
                value = getattr(element, attr_name)
                if value in (0, None):
                    num_zero_none_values += 1
                else:
                    total += value
                    if value < min_value:
                        min_value = value

        if total == 0 and num_zero_none_values == len(elements):
            if not lenient:
                raise ValueError(
                    "All element values are 0 or None, cannot divide segment."
                )
            else:
                # robust reaction on issue
                symmetry_mode = "count"
                num_zero_none_values = 0
                total = len(elements)

        # Correct handling when all values are not 0/None
        # and therefore  min_value was not updated
        if num_zero_none_values > 0:
            # Adjust total value for 0/None values
            # we use the min_value as a default
            total += min_value * num_zero_none_values

        start_angle = parent_segment.start_angle

        for element in elements:
            if symmetry_mode == "count":
                value = 1
            else:
                value = getattr(element, attr_name) or min_value
            proportion = value / total
            angle_span = (
                parent_segment.end_angle - parent_segment.start_angle
            ) * proportion
            end_angle = start_angle + angle_span

            segment = self.create_donut_segment(
                parent_segment, start_angle, end_angle, ringspec
            )
            sub_segments[element.path] = segment
            start_angle = end_angle

        return sub_segments

    def calculate_parent_segments(
        self, segments: Dict[str, DonutSegment], ringspec: RingSpec
    ) -> Dict[str, DonutSegment]:
        """
        Aggregates child segments into parent segments, calculating the combined start and end angles
        for each parent based on its children segments. It uses the `create_donut_segment` function to
        ensure that newly created parent segments have the correct dimensions according to the specified `ringspec`.

        Args:
            segments: A dictionary of child segments with paths as keys and DonutSegment objects as values.
            ringspec: A RingSpec object specifying the dimensions for the newly created parent segments.

        Returns:
            A dictionary of aggregated parent segments with parent paths as keys and newly created DonutSegment objects as values.
        """
        parent_segments: Dict[str, DonutSegment] = {}

        for path, segment in segments.items():
            # Extract the parent path
            parent_path = "/".join(path.split("/")[:-1])
            if parent_path not in parent_segments:
                # For a new parent segment, initialize with current segment's angles
                parent_segments[parent_path] = self.create_donut_segment(
                    parent_segment=segment,  # Assuming there's logic to determine this correctly
                    start_angle=segment.start_angle,
                    end_angle=segment.end_angle,
                    ringspec=ringspec,
                )
            else:
                # Update existing parent segment's angles
                parent_segment = parent_segments[parent_path]
                parent_segment.start_angle = min(
                    segment.start_angle, parent_segment.start_angle
                )
                parent_segment.end_angle = max(
                    segment.end_angle, parent_segment.end_angle
                )

        return parent_segments

    def calculate_segments(
        self, ct: CompetenceTree, tree_segment: DonutSegment
    ) -> Dict[str, Dict[str, DonutSegment]]:
        """
        Pre-calculate the donut segments for each level of the competence tree.

        Args:
            ct: A CompetenceTree instance for which segments are to be calculated.
            tree_segment: A DonutSegment instance representing the whole competence tree.

        Returns:
            A nested dictionary where the first-level keys are level names (e.g., 'aspect', 'area', 'facet'),
            and the second-level keys are element paths with their corresponding DonutSegment objects as values.
        """

        self.level_segments = {"aspect": {}, "area": {}, "facet": {}}

        symmetry_level, symmetry_mode = ct.get_symmetry_spec()
        symmetry_elements = ct.elements_by_level[symmetry_level]
        sub_segments = self.calculate_sub_segments(
            ct, tree_segment, symmetry_level, symmetry_mode, symmetry_elements
        )
        self.level_segments[symmetry_level] = sub_segments
        if symmetry_level == "facet":
            # work from outer level to inner
            area_segments = self.calculate_parent_segments(
                sub_segments, ct.ring_specs["area"]
            )
            self.level_segments["area"] = area_segments
            aspect_segments = self.calculate_parent_segments(
                area_segments, ct.ring_specs["aspect"]
            )
            self.level_segments["aspect"] = aspect_segments
        elif symmetry_level == "area":
            # work from outer level to inner
            area_segments = sub_segments
            aspect_segments = self.calculate_parent_segments(
                area_segments, ct.ring_specs["aspect"]
            )
            self.level_segments["aspect"] = aspect_segments
            # work from middle level to outer
            for area_path, area_segment in area_segments.items():
                area = ct.elements_by_path[area_path]
                facet_segments = self.calculate_sub_segments(
                    ct, area_segment, "facet", symmetry_mode, area.facets
                )
                self.level_segments["facet"].update(facet_segments)
        elif symmetry_level == "aspect":
            # work from inner level to outer
            for aspect_path, aspect_segment in sub_segments.items():
                aspect = ct.elements_by_path[aspect_path]
                area_segments = self.calculate_sub_segments(
                    ct, aspect_segment, "area", symmetry_mode, aspect.areas
                )
                self.level_segments["area"].update(area_segments)
                for area_path, area_segment in area_segments.items():
                    area = ct.elements_by_path[area_path]
                    facet_segments = self.calculate_sub_segments(
                        ct, area_segment, "facet", symmetry_mode, area.facets
                    )
                    self.level_segments["facet"].update(facet_segments)

        else:
            raise ValueError(f"Invalid symmetry_level {symmetry_level}")
        return self.level_segments

    def generate_svg_markup(
        self,
        competence_tree: CompetenceTree = None,
        learner: Learner = None,
        selected_paths: List = [],
        config: SVGConfig = None,
        with_java_script: bool = True,
        text_mode: str = "empty",
        lookup_url: str = "",
    ) -> str:
        """
        Generate the SVG markup for the given CompetenceTree and Learner. This method
        creates an SVG representation of the competence map, which visualizes the
        structure and levels of competencies, along with highlighting the learner's
        achievements if provided.

        Args:
            competence_tree (CompetenceTree, optional): The competence tree structure
                to be visualized. If None, the competence tree of the DcmChart instance
                will be used. Defaults to None.
            learner (Learner, optional): The learner whose achievements are to be
                visualized on the competence tree. If None, no learner-specific
                information will be included in the SVG. Defaults to None.
            selected_paths (List, optional): A list of paths that should be highlighted
                in the SVG. These paths typically represent specific competencies or
                achievements. Defaults to an empty list.
            config (SVGConfig, optional): Configuration for the SVG canvas and legend.
                If None, default configuration settings are used. Defaults to None.
            text_mode(str): text display mode
            with_java_script (bool, optional): Indicates whether to include JavaScript
                in the SVG for interactivity. Defaults to True.
            lookup_url (str, optional): Base URL for linking to detailed descriptions
                or information about the competence elements. If not provided, links
                will not be generated. Defaults to an empty string.

        Returns:
            str: A string containing the SVG markup for the competence map.

        Raises:
            ValueError: If there are inconsistencies or issues with the provided data
                that prevent the creation of a valid SVG.
        """
        if competence_tree is None:
            competence_tree = self.dcm.competence_tree
        self.selected_paths = selected_paths

        competence_tree.calculate_ring_specs(text_mode)
        svg = self.prepare_and_add_inner_circle(config, competence_tree, lookup_url)

        segment = DonutSegment(
            cx=self.cx, cy=self.cy, inner_radius=0, outer_radius=self.tree_radius
        )
        segments = self.calculate_segments(competence_tree, segment)
        self.generate_pie_elements_for_segments(
            svg=svg, ct=competence_tree, segments=segments, learner=learner
        )
        # self.generate_pie_elements(
        #    level=1,
        #    svg=svg,
        #    ct=competence_tree,
        #    parent_element=competence_tree,
        #    learner=learner,
        #    segment=segment,
        # )
        if svg.config.legend_height > 0:
            competence_tree.add_legend(svg)

        return svg.get_svg_markup(with_java_script=with_java_script)

    def save_svg_to_file(self, svg_markup: str, filename: str):
        """
        Save the SVG content to a file
        """
        with open(filename, "w") as file:
            file.write(svg_markup)

__init__(dcm)

Constructor

Source code in dcm/dcm_chart.py
26
27
28
29
30
def __init__(self, dcm: DynamicCompetenceMap):
    """
    Constructor
    """
    self.dcm = dcm

add_donut_segment(svg, element, segment, level_color=None, achievement_level=None, ringspec=None)

create a donut segment for the given competence element and add it to the given SVG

if level color is available an achievement needs to be potentially shown

Source code in dcm/dcm_chart.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
def add_donut_segment(
    self,
    svg: SVG,
    element: CompetenceElement,
    segment: DonutSegment,
    level_color=None,
    achievement_level=None,
    ringspec: RingSpec = None,
) -> DonutSegment:
    """
    create a donut segment for the
    given competence element and add it
    to the given SVG

    if level color is available an achievement
    needs to be potentially shown
    """
    element_config = self.get_element_config(element)
    # make sure we show the text on the original segment
    text_segment = copy.deepcopy(segment)

    if level_color:
        element_config.fill = level_color  # Set the color
    if element and element.path in self.selected_paths:
        element_config.element_class = "selected"
        element_config.color = "blue"
    # in case we need to draw an achievement
    total_levels = self.dcm.competence_tree.total_valid_levels

    if achievement_level is None:
        result = svg.add_donut_segment(config=element_config, segment=segment)
    else:
        if not self.dcm.competence_tree.stacked_levels:
            ratio = achievement_level / total_levels
            relative_radius = (segment.outer_radius - segment.inner_radius) * ratio
            segment.outer_radius = segment.inner_radius + relative_radius
            result = svg.add_donut_segment(config=element_config, segment=segment)
        else:
            # create the stacked segments starting with the highest level
            for level in range(achievement_level, 0, -1):
                stacked_segment, stack_element_config = self.get_stacked_segment(
                    level, total_levels, segment, element_config
                )
                # the result will be overriden in the loop so we'll return the innermost
                result = svg.add_donut_segment(
                    config=stack_element_config, segment=stacked_segment
                )
    if element:
        text_mode = "empty"
        if segment.text_mode:
            text_mode = segment.text_mode
        if text_mode != "empty":
            # no autofill please
            # textwrap.fill(element.short_name, width=20)
            text = element.short_name
            self.svg.add_text_to_donut_segment(
                text_segment, text, direction=text_mode
            )
    # in stacked mode show the level circles
    # by drawing arcs even if no achievements
    # are available
    if ringspec and ringspec.levels_visible:
        if total_levels:
            svg.add_element(f"<!-- arcs for {element.path} -->")
            for level in range(total_levels):
                stacked_segment, stack_element_config = self.get_stacked_segment(
                    level, total_levels, segment, element_config
                )
                fill = "white"  # Set the color for unachieved levels
                donut_path = svg.get_donut_path(
                    stacked_segment, radial_offset=1, middle_arc=True
                )  # Get the path for the stacked segment
                svg.add_element(
                    f'<path d="{donut_path}" stroke="{fill}" fill="none" stroke-width="1.5" />'
                )  # Draw the path in SVG

    return result

calculate_parent_segments(segments, ringspec)

Aggregates child segments into parent segments, calculating the combined start and end angles for each parent based on its children segments. It uses the create_donut_segment function to ensure that newly created parent segments have the correct dimensions according to the specified ringspec.

Parameters:

Name Type Description Default
segments Dict[str, DonutSegment]

A dictionary of child segments with paths as keys and DonutSegment objects as values.

required
ringspec RingSpec

A RingSpec object specifying the dimensions for the newly created parent segments.

required

Returns:

Type Description
Dict[str, DonutSegment]

A dictionary of aggregated parent segments with parent paths as keys and newly created DonutSegment objects as values.

Source code in dcm/dcm_chart.py
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
def calculate_parent_segments(
    self, segments: Dict[str, DonutSegment], ringspec: RingSpec
) -> Dict[str, DonutSegment]:
    """
    Aggregates child segments into parent segments, calculating the combined start and end angles
    for each parent based on its children segments. It uses the `create_donut_segment` function to
    ensure that newly created parent segments have the correct dimensions according to the specified `ringspec`.

    Args:
        segments: A dictionary of child segments with paths as keys and DonutSegment objects as values.
        ringspec: A RingSpec object specifying the dimensions for the newly created parent segments.

    Returns:
        A dictionary of aggregated parent segments with parent paths as keys and newly created DonutSegment objects as values.
    """
    parent_segments: Dict[str, DonutSegment] = {}

    for path, segment in segments.items():
        # Extract the parent path
        parent_path = "/".join(path.split("/")[:-1])
        if parent_path not in parent_segments:
            # For a new parent segment, initialize with current segment's angles
            parent_segments[parent_path] = self.create_donut_segment(
                parent_segment=segment,  # Assuming there's logic to determine this correctly
                start_angle=segment.start_angle,
                end_angle=segment.end_angle,
                ringspec=ringspec,
            )
        else:
            # Update existing parent segment's angles
            parent_segment = parent_segments[parent_path]
            parent_segment.start_angle = min(
                segment.start_angle, parent_segment.start_angle
            )
            parent_segment.end_angle = max(
                segment.end_angle, parent_segment.end_angle
            )

    return parent_segments

calculate_segments(ct, tree_segment)

Pre-calculate the donut segments for each level of the competence tree.

Parameters:

Name Type Description Default
ct CompetenceTree

A CompetenceTree instance for which segments are to be calculated.

required
tree_segment DonutSegment

A DonutSegment instance representing the whole competence tree.

required

Returns:

Type Description
Dict[str, Dict[str, DonutSegment]]

A nested dictionary where the first-level keys are level names (e.g., 'aspect', 'area', 'facet'),

Dict[str, Dict[str, DonutSegment]]

and the second-level keys are element paths with their corresponding DonutSegment objects as values.

Source code in dcm/dcm_chart.py
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
def calculate_segments(
    self, ct: CompetenceTree, tree_segment: DonutSegment
) -> Dict[str, Dict[str, DonutSegment]]:
    """
    Pre-calculate the donut segments for each level of the competence tree.

    Args:
        ct: A CompetenceTree instance for which segments are to be calculated.
        tree_segment: A DonutSegment instance representing the whole competence tree.

    Returns:
        A nested dictionary where the first-level keys are level names (e.g., 'aspect', 'area', 'facet'),
        and the second-level keys are element paths with their corresponding DonutSegment objects as values.
    """

    self.level_segments = {"aspect": {}, "area": {}, "facet": {}}

    symmetry_level, symmetry_mode = ct.get_symmetry_spec()
    symmetry_elements = ct.elements_by_level[symmetry_level]
    sub_segments = self.calculate_sub_segments(
        ct, tree_segment, symmetry_level, symmetry_mode, symmetry_elements
    )
    self.level_segments[symmetry_level] = sub_segments
    if symmetry_level == "facet":
        # work from outer level to inner
        area_segments = self.calculate_parent_segments(
            sub_segments, ct.ring_specs["area"]
        )
        self.level_segments["area"] = area_segments
        aspect_segments = self.calculate_parent_segments(
            area_segments, ct.ring_specs["aspect"]
        )
        self.level_segments["aspect"] = aspect_segments
    elif symmetry_level == "area":
        # work from outer level to inner
        area_segments = sub_segments
        aspect_segments = self.calculate_parent_segments(
            area_segments, ct.ring_specs["aspect"]
        )
        self.level_segments["aspect"] = aspect_segments
        # work from middle level to outer
        for area_path, area_segment in area_segments.items():
            area = ct.elements_by_path[area_path]
            facet_segments = self.calculate_sub_segments(
                ct, area_segment, "facet", symmetry_mode, area.facets
            )
            self.level_segments["facet"].update(facet_segments)
    elif symmetry_level == "aspect":
        # work from inner level to outer
        for aspect_path, aspect_segment in sub_segments.items():
            aspect = ct.elements_by_path[aspect_path]
            area_segments = self.calculate_sub_segments(
                ct, aspect_segment, "area", symmetry_mode, aspect.areas
            )
            self.level_segments["area"].update(area_segments)
            for area_path, area_segment in area_segments.items():
                area = ct.elements_by_path[area_path]
                facet_segments = self.calculate_sub_segments(
                    ct, area_segment, "facet", symmetry_mode, area.facets
                )
                self.level_segments["facet"].update(facet_segments)

    else:
        raise ValueError(f"Invalid symmetry_level {symmetry_level}")
    return self.level_segments

calculate_sub_segments(ct, parent_segment, level_name, symmetry_mode, elements, lenient=True)

Calculates and returns a dictionary of DonutSegment objects for a given level in the Competence Tree.

This method divides a parent segment into sub-segments based on the number of elements in the specified level, and assigns each sub-segment to the corresponding element's path.

Parameters:

Name Type Description Default
ct CompetenceTree

An instance of CompetenceTree representing the entire competence structure.

required
parent_segment DonutSegment

A DonutSegment instance representing the parent segment within which the sub-segments will be calculated.

required
level_name str

The name of the level (e.g., 'aspect', 'area', 'facet') for which sub-segments are being calculated.

required
symmetry_mode str

The symmetry mode ('symmetric' or 'asymmetric') affecting segment calculation.

required
elements List[CompetenceElement]

A list of CompetenceElement instances at the current level.

required
lenient bool

if True symmetry mode will be adjusted to count in case there are no values

True

Returns: A dictionary where keys are element paths and values are DonutSegment instances representing each element's segment in the visualization.

Source code in dcm/dcm_chart.py
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
def calculate_sub_segments(
    self,
    ct: CompetenceTree,
    parent_segment: DonutSegment,
    level_name: str,
    symmetry_mode: str,
    elements: List[CompetenceElement],
    lenient: bool = True,
) -> Dict[str, DonutSegment]:
    """
    Calculates and returns a dictionary of DonutSegment objects for a given level in the Competence Tree.

    This method divides a parent segment into sub-segments based on the number of elements in the specified level,
    and assigns each sub-segment to the corresponding element's path.

    Args:
        ct: An instance of CompetenceTree representing the entire competence structure.
        parent_segment: A DonutSegment instance representing the parent segment within which the sub-segments will be calculated.
        level_name: The name of the level (e.g., 'aspect', 'area', 'facet') for which sub-segments are being calculated.
        symmetry_mode: The symmetry mode ('symmetric' or 'asymmetric') affecting segment calculation.
        elements: A list of CompetenceElement instances at the current level.
        lenient (bool): if True symmetry mode will be adjusted to count in case there are no values
    Returns:
        A dictionary where keys are element paths and values are DonutSegment instances representing each element's segment in the visualization.
    """
    ringspec: RingSpec = ct.ring_specs[level_name]
    sub_segments: Dict[str, DonutSegment] = {}
    attr_names = {"time": "time", "score": "max_score"}
    if len(elements) == 0:
        return sub_segments
    num_zero_none_values = 0

    if symmetry_mode == "count":
        total = len(elements)
    else:
        attr_name = attr_names[symmetry_mode]
        total = 0
        min_value = float(
            "inf"
        )  # Initialize to infinity for proper minimum comparison

        # Initial loop to calculate total and count 0/None values
        for element in elements:
            value = getattr(element, attr_name)
            if value in (0, None):
                num_zero_none_values += 1
            else:
                total += value
                if value < min_value:
                    min_value = value

    if total == 0 and num_zero_none_values == len(elements):
        if not lenient:
            raise ValueError(
                "All element values are 0 or None, cannot divide segment."
            )
        else:
            # robust reaction on issue
            symmetry_mode = "count"
            num_zero_none_values = 0
            total = len(elements)

    # Correct handling when all values are not 0/None
    # and therefore  min_value was not updated
    if num_zero_none_values > 0:
        # Adjust total value for 0/None values
        # we use the min_value as a default
        total += min_value * num_zero_none_values

    start_angle = parent_segment.start_angle

    for element in elements:
        if symmetry_mode == "count":
            value = 1
        else:
            value = getattr(element, attr_name) or min_value
        proportion = value / total
        angle_span = (
            parent_segment.end_angle - parent_segment.start_angle
        ) * proportion
        end_angle = start_angle + angle_span

        segment = self.create_donut_segment(
            parent_segment, start_angle, end_angle, ringspec
        )
        sub_segments[element.path] = segment
        start_angle = end_angle

    return sub_segments

create_donut_segment(parent_segment, start_angle, end_angle, ringspec)

Creates a new DonutSegment based on the specified parameters, calculating its inner and outer radii based on SVG configuration and ring specifications.

Parameters:

Name Type Description Default
parent_segment DonutSegment

The parent segment from which the new segment inherits its center (cx, cy).

required
start_angle float

The starting angle of the new segment.

required
end_angle float

The ending angle of the new segment.

required
ringspec RingSpec

An instance of RingSpec defining the ratios for inner and outer radii, and the text mode.

required

Returns:

Name Type Description
DonutSegment DonutSegment

A new DonutSegment instance configured as specified.

Source code in dcm/dcm_chart.py
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
def create_donut_segment(
    self,
    parent_segment: DonutSegment,
    start_angle: float,
    end_angle: float,
    ringspec: RingSpec,
) -> DonutSegment:
    """
    Creates a new DonutSegment based on the specified parameters, calculating its
    inner and outer radii based on SVG configuration and ring specifications.

    Args:
        parent_segment (DonutSegment): The parent segment from which the new segment inherits its center (cx, cy).
        start_angle (float): The starting angle of the new segment.
        end_angle (float): The ending angle of the new segment.
        ringspec (RingSpec): An instance of RingSpec defining the ratios for inner and outer radii, and the text mode.

    Returns:
        DonutSegment: A new DonutSegment instance configured as specified.
    """
    # Calculate the actual inner and outer radii based on the SVG config and ringspec ratios
    inner_radius = self.svg.config.width / 2 * ringspec.inner_ratio
    outer_radius = self.svg.config.width / 2 * ringspec.outer_ratio
    # Create a new segment for this element
    segment = DonutSegment(
        cx=parent_segment.cx,
        cy=parent_segment.cy,
        inner_radius=inner_radius,
        outer_radius=outer_radius,
        start_angle=start_angle,
        end_angle=end_angle,
        text_mode=ringspec.text_mode,
    )
    return segment

generate_donut_segment_for_achievement(svg, learner, element, segment)

generate a donut segment for the learner's achievements corresponding to the given path and return it's segment definition

Source code in dcm/dcm_chart.py
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
def generate_donut_segment_for_achievement(
    self,
    svg: SVG,
    learner: Learner,
    element: CompetenceElement,
    segment: DonutSegment,
) -> DonutSegment:
    """
    generate a donut segment for the
    learner's achievements
    corresponding to the given path and return it's segment definition
    """
    achievement = learner.achievements_by_path.get(element.path, None)
    result = None
    if achievement and achievement.level:
        # Retrieve the color for the achievement level
        level_color = self.dcm.competence_tree.get_level_color(achievement.level)

        if level_color:
            # set the color and radius of
            # the segment for achievement
            # make sure we don't interfere with the segment calculations
            segment = copy.deepcopy(segment)
            result = self.add_donut_segment(
                svg, element, segment, level_color, achievement.level
            )
    return result

generate_donut_segment_for_element(svg, element, learner, segment, ringspec=None)

generate a donut segment for a given element of the CompetenceTree

Source code in dcm/dcm_chart.py
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
def generate_donut_segment_for_element(
    self,
    svg: SVG,
    element: CompetenceElement,
    learner: Learner,
    segment: DonutSegment,
    ringspec: RingSpec = None,
) -> DonutSegment:
    """
    generate a donut segment for a given element of
    the CompetenceTree
    """
    if segment.outer_radius == 0.0:
        result = segment
    else:
        # Simply create the donut segment without considering the achievement
        result = self.add_donut_segment(
            svg=svg, element=element, segment=segment, ringspec=ringspec
        )
        # check learner achievements
        if learner:
            _learner_segment = self.generate_donut_segment_for_achievement(
                svg=svg, learner=learner, element=element, segment=segment
            )
    return result

generate_pie_elements_for_segments(svg, ct, segments, learner)

Generate pie elements for the competence tree using pre-calculated segments.

This method will iterate through the provided segments dictionary, using each pre-calculated DonutSegment to generate and render pie elements (e.g., aspects, areas, or facets) based on the learner's achievements.

Parameters:

Name Type Description Default
svg SVG

The SVG object where the pie elements will be drawn.

required
ct CompetenceTree

The competence tree structure.

required
segments Dict[str, Dict[str, DonutSegment]]

A nested dictionary where the first key is the level name (e.g., 'aspect', 'area', 'facet'), and the second key is an element's path, mapping to its corresponding DonutSegment.

required
learner Learner

The learner object containing achievement data.

required
Source code in dcm/dcm_chart.py
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
def generate_pie_elements_for_segments(
    self,
    svg: SVG,
    ct: CompetenceTree,
    segments: Dict[str, Dict[str, DonutSegment]],
    learner: Learner,
):
    """
    Generate pie elements for the competence tree using pre-calculated segments.

    This method will iterate through the provided segments dictionary, using each pre-calculated
    DonutSegment to generate and render pie elements (e.g., aspects, areas, or facets) based on
    the learner's achievements.

    Args:
        svg (SVG): The SVG object where the pie elements will be drawn.
        ct (CompetenceTree): The competence tree structure.
        segments (Dict[str, Dict[str, DonutSegment]]): A nested dictionary where the first key is the
                                                       level name (e.g., 'aspect', 'area', 'facet'),
                                                       and the second key is an element's path, mapping
                                                       to its corresponding DonutSegment.
        learner (Learner): The learner object containing achievement data.
    """
    for level_name, segment_dict in segments.items():
        ringspec = ct.ring_specs[level_name]
        for path, segment in segment_dict.items():
            element = ct.elements_by_path.get(path, None)
            if element:
                self.generate_donut_segment_for_element(
                    svg, element, learner, segment=segment, ringspec=ringspec
                )

generate_svg(filename=None, learner=None, config=None, text_mode='empty')

Generate the SVG markup and optionally save it to a file. If a filename is given, the method will also save the SVG to that file. The SVG is generated based on internal state not shown here.

Parameters:

Name Type Description Default
filename str

The path to the file where the SVG should be saved. Defaults to None.

None
learner(Learner)

the learner to show the achievements for

required
config SVGConfig

The configuration for the SVG canvas and legend. Defaults to default values.

None
text_mode(str)

text display mode

required

Returns: str: The SVG markup.

Source code in dcm/dcm_chart.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def generate_svg(
    self,
    filename: Optional[str] = None,
    learner: Optional[Learner] = None,
    config: Optional[SVGConfig] = None,
    text_mode: str = "empty",
) -> str:
    """
    Generate the SVG markup and optionally save it to a file. If a filename is given, the method
    will also save the SVG to that file. The SVG is generated based on internal state not shown here.

    Args:
        filename (str, optional): The path to the file where the SVG should be saved. Defaults to None.
        learner(Learner): the learner to show the achievements for
        config (SVGConfig, optional): The configuration for the SVG canvas and legend. Defaults to default values.
        text_mode(str): text display mode
    Returns:
        str: The SVG markup.
    """
    if config is None:
        config = SVGConfig(
            with_popup=True
        )  # Use default configuration if none provided
    svg_markup = self.generate_svg_markup(
        self.dcm.competence_tree,
        learner=learner,
        config=config,
        text_mode=text_mode,
    )
    if filename:
        self.save_svg_to_file(svg_markup, filename)
    return svg_markup

generate_svg_markup(competence_tree=None, learner=None, selected_paths=[], config=None, with_java_script=True, text_mode='empty', lookup_url='')

Generate the SVG markup for the given CompetenceTree and Learner. This method creates an SVG representation of the competence map, which visualizes the structure and levels of competencies, along with highlighting the learner's achievements if provided.

Parameters:

Name Type Description Default
competence_tree CompetenceTree

The competence tree structure to be visualized. If None, the competence tree of the DcmChart instance will be used. Defaults to None.

None
learner Learner

The learner whose achievements are to be visualized on the competence tree. If None, no learner-specific information will be included in the SVG. Defaults to None.

None
selected_paths List

A list of paths that should be highlighted in the SVG. These paths typically represent specific competencies or achievements. Defaults to an empty list.

[]
config SVGConfig

Configuration for the SVG canvas and legend. If None, default configuration settings are used. Defaults to None.

None
text_mode(str)

text display mode

required
with_java_script bool

Indicates whether to include JavaScript in the SVG for interactivity. Defaults to True.

True
lookup_url str

Base URL for linking to detailed descriptions or information about the competence elements. If not provided, links will not be generated. Defaults to an empty string.

''

Returns:

Name Type Description
str str

A string containing the SVG markup for the competence map.

Raises:

Type Description
ValueError

If there are inconsistencies or issues with the provided data that prevent the creation of a valid SVG.

Source code in dcm/dcm_chart.py
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
def generate_svg_markup(
    self,
    competence_tree: CompetenceTree = None,
    learner: Learner = None,
    selected_paths: List = [],
    config: SVGConfig = None,
    with_java_script: bool = True,
    text_mode: str = "empty",
    lookup_url: str = "",
) -> str:
    """
    Generate the SVG markup for the given CompetenceTree and Learner. This method
    creates an SVG representation of the competence map, which visualizes the
    structure and levels of competencies, along with highlighting the learner's
    achievements if provided.

    Args:
        competence_tree (CompetenceTree, optional): The competence tree structure
            to be visualized. If None, the competence tree of the DcmChart instance
            will be used. Defaults to None.
        learner (Learner, optional): The learner whose achievements are to be
            visualized on the competence tree. If None, no learner-specific
            information will be included in the SVG. Defaults to None.
        selected_paths (List, optional): A list of paths that should be highlighted
            in the SVG. These paths typically represent specific competencies or
            achievements. Defaults to an empty list.
        config (SVGConfig, optional): Configuration for the SVG canvas and legend.
            If None, default configuration settings are used. Defaults to None.
        text_mode(str): text display mode
        with_java_script (bool, optional): Indicates whether to include JavaScript
            in the SVG for interactivity. Defaults to True.
        lookup_url (str, optional): Base URL for linking to detailed descriptions
            or information about the competence elements. If not provided, links
            will not be generated. Defaults to an empty string.

    Returns:
        str: A string containing the SVG markup for the competence map.

    Raises:
        ValueError: If there are inconsistencies or issues with the provided data
            that prevent the creation of a valid SVG.
    """
    if competence_tree is None:
        competence_tree = self.dcm.competence_tree
    self.selected_paths = selected_paths

    competence_tree.calculate_ring_specs(text_mode)
    svg = self.prepare_and_add_inner_circle(config, competence_tree, lookup_url)

    segment = DonutSegment(
        cx=self.cx, cy=self.cy, inner_radius=0, outer_radius=self.tree_radius
    )
    segments = self.calculate_segments(competence_tree, segment)
    self.generate_pie_elements_for_segments(
        svg=svg, ct=competence_tree, segments=segments, learner=learner
    )
    # self.generate_pie_elements(
    #    level=1,
    #    svg=svg,
    #    ct=competence_tree,
    #    parent_element=competence_tree,
    #    learner=learner,
    #    segment=segment,
    # )
    if svg.config.legend_height > 0:
        competence_tree.add_legend(svg)

    return svg.get_svg_markup(with_java_script=with_java_script)

get_element_config(element)

get a configuration for the given element

Parameters:

Name Type Description Default
element(CompetenceElement)

the element

required
Return

SVGNodeConfig: an SVG Node configuration

Source code in dcm/dcm_chart.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
def get_element_config(self, element: CompetenceElement) -> SVGNodeConfig:
    """
    get a configuration for the given element

    Args:
        element(CompetenceElement): the element

    Return:
        SVGNodeConfig: an SVG Node configuration
    """
    if element is None:
        element_config = SVGNodeConfig(x=self.cx, y=self.cy, fill="white")
        return element_config
    element_url = (
        element.url
        if element.url
        else (
            f"{self.lookup_url}/description/{element.path}"
            if self.lookup_url is not None
            else None
        )
    )
    show_as_popup = element.url is None
    element_config = element.to_svg_node_config(
        url=element_url,
        show_as_popup=show_as_popup,
        x=self.cx,
        y=self.cy,
    )
    return element_config

get_stacked_segment(level, total_levels, segment, element_config)

Calculate the stacked segment for a given level.

Parameters:

Name Type Description Default
level int

The current level for which to calculate the segment.

required
total_levels int

The total number of levels.

required
segment DonutSegment

The original donut Segment.

required
element_config SVGNodeConfig

The element configuration.

required

Returns:

Type Description
Tuple[DonutSegment, SVGNodeConfig]

Tuple[DonutSegment, SVGNodeConfig]: The calculated stacked segment and its configuration for the given level.

Source code in dcm/dcm_chart.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
def get_stacked_segment(
    self,
    level: int,
    total_levels: int,
    segment: DonutSegment,
    element_config: SVGNodeConfig,
) -> Tuple[DonutSegment, SVGNodeConfig]:
    """
    Calculate the stacked segment for a given level.

    Args:
        level (int): The current level for which to calculate the segment.
        total_levels (int): The total number of levels.
        segment (DonutSegment): The original donut Segment.
        element_config (SVGNodeConfig): The element configuration.

    Returns:
        Tuple[DonutSegment, SVGNodeConfig]:
            The calculated stacked segment and its configuration for the given level.

    """
    level_color = self.dcm.competence_tree.get_level_color(level)
    stack_element_config = copy.deepcopy(element_config)
    stack_element_config.fill = level_color
    ratio = level / total_levels
    relative_radius = (segment.outer_radius - segment.inner_radius) * ratio
    stacked_segment = copy.deepcopy(segment)
    stacked_segment.outer_radius = segment.inner_radius + relative_radius
    return stacked_segment, stack_element_config

prepare_and_add_inner_circle(config, competence_tree, lookup_url=None)

prepare the SVG markup generation and add the inner_circle

Source code in dcm/dcm_chart.py
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
def prepare_and_add_inner_circle(
    self, config, competence_tree: CompetenceTree, lookup_url: str = None
):
    """
    prepare the SVG markup generation and add
    the inner_circle
    """
    self.lookup_url = (
        competence_tree.lookup_url if competence_tree.lookup_url else lookup_url
    )

    svg = SVG(config)
    self.svg = svg
    config = svg.config
    # center of circle
    self.cx = config.width // 2
    self.cy = (config.total_height - config.legend_height) // 2
    ringspec = competence_tree.ring_specs.get("tree")
    self.tree_radius = ringspec.outer_ratio * config.width / 2

    self.circle_config = competence_tree.to_svg_node_config(
        x=self.cx, y=self.cy, width=self.tree_radius
    )
    svg.add_circle(config=self.circle_config)
    if ringspec.text_mode != "empty":
        svg.add_text(
            self.cx,
            self.cy,
            competence_tree.short_name,
            text_anchor="middle",
            center_v=True,
            fill="white",
        )
    return svg

save_svg_to_file(svg_markup, filename)

Save the SVG content to a file

Source code in dcm/dcm_chart.py
625
626
627
628
629
630
def save_svg_to_file(self, svg_markup: str, filename: str):
    """
    Save the SVG content to a file
    """
    with open(filename, "w") as file:
        file.write(svg_markup)

dcm_cmd

Created on 2023-11-06

@author: wf

CompetenceCmd

Bases: WebserverCmd

Command line for diagrams server

Source code in dcm/dcm_cmd.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class CompetenceCmd(WebserverCmd):
    """
    Command line for diagrams server
    """

    def getArgParser(self, description: str, version_msg) -> ArgumentParser:
        """
        override the default argparser call
        """
        parser = super().getArgParser(description, version_msg)
        parser.add_argument(
            "-v",
            "--verbose",
            action="store_true",
            help="show verbose output [default: %(default)s]",
        )
        parser.add_argument(
            "-rp",
            "--root_path",
            default=DynamicCompetenceMap.examples_path(),
            help="path to example dcm definition files [default: %(default)s]",
        )
        return parser

getArgParser(description, version_msg)

override the default argparser call

Source code in dcm/dcm_cmd.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def getArgParser(self, description: str, version_msg) -> ArgumentParser:
    """
    override the default argparser call
    """
    parser = super().getArgParser(description, version_msg)
    parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="show verbose output [default: %(default)s]",
    )
    parser.add_argument(
        "-rp",
        "--root_path",
        default=DynamicCompetenceMap.examples_path(),
        help="path to example dcm definition files [default: %(default)s]",
    )
    return parser

main(argv=None)

main call

Source code in dcm/dcm_cmd.py
41
42
43
44
45
46
47
48
49
50
def main(argv: list = None):
    """
    main call
    """
    cmd = CompetenceCmd(
        config=DynamicCompentenceMapWebServer.get_config(),
        webserver_cls=DynamicCompentenceMapWebServer,
    )
    exit_code = cmd.cmd_main(argv)
    return exit_code

dcm_core

Created on 2023-06-11

@author: wf

Achievement dataclass

Class representing an individual's achievement level for a specific competence facet.

Attributes:

Name Type Description
path str

The path in the CompetenceTree, used to derive tree_id, aspect_id, and facet_id.

level int

The achieved level for this facet.

score float

How well the achievement was reached.

score_unit str

Unit of the score, default is "%".

evidence Optional[str]

Optional evidence supporting the achievement.

date_assessed Optional[str]

Optional date when the achievement was assessed (ISO-Format).

Source code in dcm/dcm_core.py
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
@dataclass_json
@dataclass
class Achievement:
    """
    Class representing an individual's achievement level for a specific competence facet.

    Attributes:
        path (str): The path in the CompetenceTree, used to derive tree_id, aspect_id, and facet_id.
        level (int): The achieved level for this facet.
        score (float): How well the achievement was reached.
        score_unit (str): Unit of the score, default is "%".
        evidence (Optional[str]): Optional evidence supporting the achievement.
        date_assessed (Optional[str]): Optional date when the achievement was assessed (ISO-Format).
    """

    path: str
    level: Optional[int] = None
    score: Optional[float] = None
    score_unit: Optional[str] = "%"
    evidence: Optional[str] = None
    date_assessed_iso: Optional[str] = None

    @property
    def tree_id(self):
        parts = self.path.split("/")
        return parts[0] if parts else None

    @property
    def aspect_id(self):
        parts = self.path.split("/")
        return parts[1] if len(parts) > 1 else None

    @property
    def area_id(self):
        parts = self.path.split("/")
        return parts[2] if len(parts) > 2 else None

    @property
    def facet_id(self):
        parts = self.path.split("/")
        return parts[3] if len(parts) > 3 else None

CompetenceArea dataclass

Bases: CompetenceElement

Represents a specific area within a competence aspect, containing various facets.

Attributes:

Name Type Description
facets List[CompetenceFacet]

A list of CompetenceFacet objects representing individual facets of this area.

Source code in dcm/dcm_core.py
139
140
141
142
143
144
145
146
147
148
149
@dataclass_json
@dataclass
class CompetenceArea(CompetenceElement):
    """
    Represents a specific area within a competence aspect, containing various facets.

    Attributes:
        facets (List[CompetenceFacet]): A list of CompetenceFacet objects representing individual facets of this area.
    """

    facets: List[CompetenceFacet] = field(default_factory=list)

CompetenceAspect dataclass

Bases: CompetenceElement

Represents a broader category of competence, which includes various areas.

Attributes:

Name Type Description
areas List[CompetenceArea]

A list of CompetenceArea objects representing individual areas of this aspect.

Source code in dcm/dcm_core.py
152
153
154
155
156
157
158
159
160
161
162
@dataclass_json
@dataclass
class CompetenceAspect(CompetenceElement):
    """
    Represents a broader category of competence, which includes various areas.

    Attributes:
        areas (List[CompetenceArea]): A list of CompetenceArea objects representing individual areas of this aspect.
    """

    areas: List[CompetenceArea] = field(default_factory=list)

CompetenceElement dataclass

A base class representing a generic competence element with common properties.

Attributes:

Name Type Description
name str

The name of the competence element.

short_name(Optional[str]) str

the label to be displayed

id Optional[str]

An optional identifier for the competence element will be set to the name if id is None.

url Optional[str]

An optional URL for more information about the competence element.

description Optional[str]

An optional description of the competence element.

color_code str

A string representing a (fill) color code associated with the competence element.

border_color str

A string representing the border color to be used e.g. "black" or "#ffffff"

Source code in dcm/dcm_core.py
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
@dataclass_json
@dataclass
class CompetenceElement:
    """
    A base class representing a generic competence element with common properties.

    Attributes:
        name (str): The name of the competence element.
        short_name(Optional[str]): the label to be displayed
        id (Optional[str]): An optional identifier for the competence element will be set to the name if id is None.
        url (Optional[str]): An optional URL for more information about the competence element.
        description (Optional[str]): An optional description of the competence element.
        color_code (str): A string representing a (fill) color code associated with the competence element.
        border_color (str): A string representing the border color to be used e.g. "black" or "#ffffff"
    """

    name: str
    short_name: Optional[str] = None
    id: Optional[str] = None
    url: Optional[str] = None
    description: Optional[str] = None
    color_code: Optional[str] = None
    border_color: Optional[str] = None

    time: Optional[float] = None
    time_unit: Optional[str] = None  # h/CP
    max_score: Optional[float] = None  # 100
    score_unit: Optional[str] = None  # %

    def __post_init__(self):
        # Set the id to the the slug of the name if id is None
        if self.id is None:
            # https://pypi.org/project/python-slugify/
            self.id = slugify(self.name, lowercase=False, regex_pattern=r"[^\w\-]")
        if self.short_name is None:
            self.short_name = self.name[:10]

    def as_html(self) -> str:
        """
        convert me to html

        Returns:
            str: html markup
        """
        html = f"<h2>{self.name}</h2>"
        if self.description:
            desc_html = markdown2.markdown(
                self.description, extras=["fenced-code-blocks", "tables", "spoiler"]
            )
            html = html + "\n" + desc_html
        return html

    def to_svg_node_config(self, url: str = None, **kwargs) -> SVGNodeConfig:
        """
        convert me to an SVGNode Configuration

        Args:
            url(str): the url to use for clicking this svg node - if None use
            my configured url
        """
        if url is None:
            url = self.url
        element_type = f"{self.__class__.__name__}"
        comment = f"{element_type}:{self.description}"
        svg_node_config = SVGNodeConfig(
            element_type=f"{element_type}",
            id=f"{self.id}",
            url=url,
            fill=self.color_code,
            color=self.border_color,
            title=self.name,
            comment=comment,
            **kwargs,
        )
        return svg_node_config

as_html()

convert me to html

Returns:

Name Type Description
str str

html markup

Source code in dcm/dcm_core.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def as_html(self) -> str:
    """
    convert me to html

    Returns:
        str: html markup
    """
    html = f"<h2>{self.name}</h2>"
    if self.description:
        desc_html = markdown2.markdown(
            self.description, extras=["fenced-code-blocks", "tables", "spoiler"]
        )
        html = html + "\n" + desc_html
    return html

to_svg_node_config(url=None, **kwargs)

convert me to an SVGNode Configuration

Parameters:

Name Type Description Default
url(str)

the url to use for clicking this svg node - if None use

required
Source code in dcm/dcm_core.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def to_svg_node_config(self, url: str = None, **kwargs) -> SVGNodeConfig:
    """
    convert me to an SVGNode Configuration

    Args:
        url(str): the url to use for clicking this svg node - if None use
        my configured url
    """
    if url is None:
        url = self.url
    element_type = f"{self.__class__.__name__}"
    comment = f"{element_type}:{self.description}"
    svg_node_config = SVGNodeConfig(
        element_type=f"{element_type}",
        id=f"{self.id}",
        url=url,
        fill=self.color_code,
        color=self.border_color,
        title=self.name,
        comment=comment,
        **kwargs,
    )
    return svg_node_config

CompetenceFacet dataclass

Bases: CompetenceElement

Represents a specific facet of a competence aspect, inheriting from CompetenceElement.

This class can include additional properties or methods specific to a competence facet.

Source code in dcm/dcm_core.py
129
130
131
132
133
134
135
136
@dataclass_json
@dataclass
class CompetenceFacet(CompetenceElement):
    """
    Represents a specific facet of a competence aspect, inheriting from CompetenceElement.

    This class can include additional properties or methods specific to a competence facet.
    """

CompetenceLevel dataclass

Bases: CompetenceElement

Defines a specific level of competence within the framework.

Attributes:

Name Type Description
level int

level number starting from 1 as the lowest and going up to as many level as defined for the CompetenceTree

icon(str) int

the name of a google mdi icon to be shown for this level

utf8_icon(str) int

utf-8 char string to be used as icon

Source code in dcm/dcm_core.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
@dataclass_json
@dataclass
class CompetenceLevel(CompetenceElement):
    """
    Defines a specific level of competence within the framework.

    Attributes:
        level (int): level number starting from 1 as the lowest and going up to as many level as defined for the CompetenceTree
        icon(str): the name of a google mdi icon to be shown for this level
        utf8_icon(str): utf-8 char string to be used as icon
    """

    level: int = 1
    icon: Optional[str] = None
    utf8_icon: Optional[str] = None

CompetenceTree dataclass

Bases: CompetenceElement, YamlAble['CompetenceTree']

Represents the entire structure of competencies, including various aspects and levels.

Attributes:

Name Type Description
lookup_url Optional[str]

Optional URL for additional information.

total_levels int

Total number of levels in the competence hierarchy.

stacked_levels Optional[bool]

Indicates whether the levels are stacked.

aspects List[CompetenceAspect]

A list of CompetenceAspect objects.

levels List[CompetenceLevel]

A list of CompetenceLevel objects.

element_names Dict[str, str]

A dictionary holding the names for tree, aspects, facets, and levels. The key is the type ("tree", "aspect", "facet", "level").

ring_specs Dict[str, RingSpec]

Specifications for the rings in the donut chart.

total_elements Dict[str, int]

A dictionary holding the total number of elements for each type (aspects, areas, facets).

Source code in dcm/dcm_core.py
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
@dataclass_json
@dataclass
class CompetenceTree(CompetenceElement, YamlAble["CompetenceTree"]):
    """
    Represents the entire structure of competencies, including various aspects and levels.

    Attributes:
        lookup_url (Optional[str]): Optional URL for additional information.
        total_levels (int): Total number of levels in the competence hierarchy.
        stacked_levels (Optional[bool]): Indicates whether the levels are stacked.
        aspects (List[CompetenceAspect]): A list of CompetenceAspect objects.
        levels (List[CompetenceLevel]): A list of CompetenceLevel objects.
        element_names (Dict[str, str]): A dictionary holding the names for tree, aspects, facets, and levels.  The key is the type ("tree", "aspect", "facet", "level").
        ring_specs (Dict[str, RingSpec]): Specifications for the rings in the donut chart.
        total_elements (Dict[str, int]): A dictionary holding the total number of elements for each type (aspects, areas, facets).
    """

    lookup_url: Optional[str] = None
    total_levels: int = field(init=False)
    stacked_levels: Optional[bool] = False
    aspects: List[CompetenceAspect] = field(default_factory=list)
    levels: List[CompetenceLevel] = field(default_factory=list)
    element_names: Dict[str, str] = field(default_factory=dict)
    ring_specs: Dict[str, RingSpec] = field(default_factory=dict)
    total_elements: Dict[str, int] = field(default_factory=dict)

    def __post_init__(self):
        """
        initalize the path variables of my hierarchy
        """
        super().__post_init__()
        self.update_paths()
        self.calculate_ring_specs("empty")

    def calculate_ring_specs(self, text_mode: str):
        """
        calculate the ring specifications
        """
        inner_ratio = 0.0
        circle_ratio = 1 / (self.total_levels * 2 + 1)
        outer_ratio = circle_ratio
        if not "tree" in self.ring_specs:
            self.ring_specs["tree"] = RingSpec(
                text_mode=text_mode, inner_ratio=0.0, outer_ratio=circle_ratio
            )
        # loop over ring levels
        for rl in ["aspect", "area", "facet"]:
            inner_ratio = outer_ratio
            outer_ratio = outer_ratio + circle_ratio * 2
            if not rl in self.ring_specs:
                self.ring_specs[rl] = RingSpec(
                    text_mode=text_mode,
                    inner_ratio=inner_ratio,
                    outer_ratio=outer_ratio,
                )

    def set_symmetry_mode(self, symmetry_level: str, symmetry_mode: str):
        """
         Sets a new symmetry mode for a specified level
         in the competence tree. It resets the symmetry
         mode for all levels to None and then
         applies the new symmetry mode to the
         specified level. This method ensures that
         only one level has a symmetry mode set at any given time.

        Args:
            symmetry_level(str): The level to apply the new symmetry mode. Valid levels include "tree", "aspect", "area", and "facet".
            symmetry_mode (str): The symmetry mode to set for the specified level. Valid modes are "count", "score", and "time".
        """
        # reset all ring specs
        for rl in ["tree", "aspect", "area", "facet"]:
            self.ring_specs[rl].symmetry_mode = None
        if symmetry_level in self.ring_specs:
            self.ring_specs[symmetry_level].symmetry_mode = symmetry_mode

    def get_symmetry_spec(self) -> Tuple[str, str]:
        """
        Get the symmetry specification from the ring_specs, ensuring only one spec has the symmetry mode set.

        Returns:
            Tuple[str, str]: A pair of symmetry mode and the level it is applied to.

        Raises:
            ValueError: If multiple RingSpecs have a symmetry mode set.
        """

        symmetry_modes = [
            (level, spec.symmetry_mode)
            for level, spec in self.ring_specs.items()
            if spec.symmetry_mode
        ]
        if len(symmetry_modes) > 1:
            raise ValueError("Symmetry mode set for multiple ring specs.")
        elif len(symmetry_modes) == 1:
            level, mode = symmetry_modes[0]
        else:
            # Default to "count" and CompetenceFacet level if no spec is given
            level, mode = ("facet", "count")
        if not mode in ["count", "score", "time"]:
            raise ValueError(
                f"Invalid symmetry mode {mode} - must be count, score or time"
            )
        return (level, mode)

    def get_elements_for_level(self, level: int) -> List[CompetenceElement]:
        """
        get the elements for the given hierarchy level

        Args:
            level(int): the hierarchy level

        Returns:
            List(CompetencElement): the list of elements on this level
        """
        level_name = self.level_names[level]
        elements = self.elements_by_level[level_name]
        return elements

    def update_paths(self):
        """
        update my paths
        """
        self.level_names = ["tree", "aspect", "area", "facet"]
        self.level_attr_names = [None, "aspects", "areas", "facets"]
        self.total_elements = {"tree": 1, "aspects": 0, "areas": 0, "facets": 0}
        self.elements_by_level = {
            "tree": [self],
            "aspect": [],
            "area": [],
            "facet": [],
        }  # Reset for re-calculation

        self.path = self.id
        self.total_levels = 1
        self.elements_by_path = {self.path: self}
        # Loop through each competence aspect and set their paths and parent references
        for aspect in self.aspects:
            aspect.competence_tree = self
            aspect.path = f"{self.id}/{aspect.id}"
            self.elements_by_path[aspect.path] = aspect
            self.total_elements["aspects"] = self.total_elements["aspects"] + 1
            self.total_levels = 2
            self.elements_by_level["aspect"].append(aspect)
            for area in aspect.areas:
                self.total_levels = 3
                area.competence_tree = self
                area.aspect = aspect
                area.path = f"{self.id}/{aspect.id}/{area.id}"
                self.elements_by_path[area.path] = area
                self.total_elements["areas"] = self.total_elements["areas"] + 1
                self.elements_by_level["area"].append(area)
                for facet in area.facets:
                    self.total_levels = 4
                    facet.competence_tree = self
                    facet.area = area
                    facet.path = f"{self.id}/{aspect.id}/{area.id}/{facet.id}"
                    self.elements_by_path[facet.path] = facet
                    self.total_elements["facets"] = self.total_elements["facets"] + 1
                    self.elements_by_level["facet"].append(facet)

    @classmethod
    def required_keys(cls) -> Tuple:
        keys = {"name", "id", "url", "description", "aspects"}
        return keys

    def lookup_by_path(
        self, path: str, lenient: bool = True
    ) -> Optional[CompetenceElement]:
        """
        Look up and return a competence element (tree,aspect of facet)
        based on the given path.

        The path is expected to be in the format "tree_id/aspect_id/facet_id".
        This method parses the path and retrieves the corresponding competence aspect or facet.

        Args:
            path (str): The path in the format "tree_id/aspect_id/facet_id".

            lenient(bool): if not lenient raise Exceptions for invalid paths and ids
        Returns:
            Optional[CompetenceElement]: The competence aspect or facet corresponding to the given path.
        """

        def handle_error(msg):
            if not lenient:
                raise ValueError(msg)

        element = None
        if path not in self.elements_by_path:
            msg = f"invalid path {path}"
            if not lenient:
                raise ValueError(msg)
        else:
            element = self.elements_by_path.get(path)
        return element

    @property
    def total_valid_levels(self) -> int:
        """
        Calculate the total number of levels excluding
        levels with a level of 0.

        Returns:
            int: The total number of valid levels.
        """
        level_count = len([level for level in self.levels if level.level != 0])
        return level_count

    def get_level_color(self, achievement_level: int) -> Optional[str]:
        """
        Retrieve the color associated with a specific achievement level.

        Args:
            achievement_level (int): The level of achievement to get the color for.

        Returns:
            Optional[str]: The color code associated with the given level, or None if not found.
        """
        for level in self.levels:
            if level.level == achievement_level:
                return level.color_code
        return None

    def to_pretty_json(self):
        """
        Converts the CompetenceTree object to a pretty JSON string, handling null values.
        """
        json_str = self.to_json()
        json_dict = json.loads(json_str)

        def remove_none_values(data):
            """
            Recursively removes keys with None values from a dictionary, list, or nested structure.
            """
            if isinstance(data, dict):
                return {
                    k: remove_none_values(v) for k, v in data.items() if v is not None
                }
            elif isinstance(data, list):
                return [remove_none_values(item) for item in data]
            return data

        none_free_dict = remove_none_values(json_dict)
        null_free_json_str = json.dumps(none_free_dict, indent=2)
        return null_free_json_str

    def add_legend(self, svg: SVG) -> None:
        """
        Add a legend to the SVG explaining the color codes for levels and aspects.
        Args:
            svg (SVG): The SVG object to which the legend will be added.
        """
        # Starting x position for the legends, starting 10 pixels from the left edge
        x_start = 10
        # y position for the legends, starting 20 pixels from the bottom edge
        y = svg.config.total_height - svg.config.legend_height + 20
        # Width and height of each legend color box
        box_width, box_height = 30, 20
        # Padding between legend items and between the color box and the text
        padding = 5

        # Add the competence level legend
        level_items = [(level.color_code, level.name) for level in self.levels]
        svg.add_legend_column(
            level_items,
            self.element_names.get("level", "Level"),
            x_start,
            y,
            box_width,
            box_height,
        )
        max_name_width = 5
        if self.levels:
            max_name_width = max(
                svg.get_text_width(level.name) for level in self.levels
            )
        # Calculate the x position for the aspect legend based on the width of the level legend
        x_aspect_start = x_start + box_width + padding + max_name_width + padding

        # Add the competence aspect legend
        aspect_items = [(aspect.color_code, aspect.name) for aspect in self.aspects]
        svg.add_legend_column(
            aspect_items,
            self.element_names.get("aspect", "Aspect"),
            x_aspect_start,
            y,
            box_width,
            box_height,
        )

total_valid_levels: int property

Calculate the total number of levels excluding levels with a level of 0.

Returns:

Name Type Description
int int

The total number of valid levels.

__post_init__()

initalize the path variables of my hierarchy

Source code in dcm/dcm_core.py
208
209
210
211
212
213
214
def __post_init__(self):
    """
    initalize the path variables of my hierarchy
    """
    super().__post_init__()
    self.update_paths()
    self.calculate_ring_specs("empty")

add_legend(svg)

Add a legend to the SVG explaining the color codes for levels and aspects. Args: svg (SVG): The SVG object to which the legend will be added.

Source code in dcm/dcm_core.py
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
def add_legend(self, svg: SVG) -> None:
    """
    Add a legend to the SVG explaining the color codes for levels and aspects.
    Args:
        svg (SVG): The SVG object to which the legend will be added.
    """
    # Starting x position for the legends, starting 10 pixels from the left edge
    x_start = 10
    # y position for the legends, starting 20 pixels from the bottom edge
    y = svg.config.total_height - svg.config.legend_height + 20
    # Width and height of each legend color box
    box_width, box_height = 30, 20
    # Padding between legend items and between the color box and the text
    padding = 5

    # Add the competence level legend
    level_items = [(level.color_code, level.name) for level in self.levels]
    svg.add_legend_column(
        level_items,
        self.element_names.get("level", "Level"),
        x_start,
        y,
        box_width,
        box_height,
    )
    max_name_width = 5
    if self.levels:
        max_name_width = max(
            svg.get_text_width(level.name) for level in self.levels
        )
    # Calculate the x position for the aspect legend based on the width of the level legend
    x_aspect_start = x_start + box_width + padding + max_name_width + padding

    # Add the competence aspect legend
    aspect_items = [(aspect.color_code, aspect.name) for aspect in self.aspects]
    svg.add_legend_column(
        aspect_items,
        self.element_names.get("aspect", "Aspect"),
        x_aspect_start,
        y,
        box_width,
        box_height,
    )

calculate_ring_specs(text_mode)

calculate the ring specifications

Source code in dcm/dcm_core.py
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
def calculate_ring_specs(self, text_mode: str):
    """
    calculate the ring specifications
    """
    inner_ratio = 0.0
    circle_ratio = 1 / (self.total_levels * 2 + 1)
    outer_ratio = circle_ratio
    if not "tree" in self.ring_specs:
        self.ring_specs["tree"] = RingSpec(
            text_mode=text_mode, inner_ratio=0.0, outer_ratio=circle_ratio
        )
    # loop over ring levels
    for rl in ["aspect", "area", "facet"]:
        inner_ratio = outer_ratio
        outer_ratio = outer_ratio + circle_ratio * 2
        if not rl in self.ring_specs:
            self.ring_specs[rl] = RingSpec(
                text_mode=text_mode,
                inner_ratio=inner_ratio,
                outer_ratio=outer_ratio,
            )

get_elements_for_level(level)

get the elements for the given hierarchy level

Parameters:

Name Type Description Default
level(int)

the hierarchy level

required

Returns:

Name Type Description
List CompetencElement

the list of elements on this level

Source code in dcm/dcm_core.py
286
287
288
289
290
291
292
293
294
295
296
297
298
def get_elements_for_level(self, level: int) -> List[CompetenceElement]:
    """
    get the elements for the given hierarchy level

    Args:
        level(int): the hierarchy level

    Returns:
        List(CompetencElement): the list of elements on this level
    """
    level_name = self.level_names[level]
    elements = self.elements_by_level[level_name]
    return elements

get_level_color(achievement_level)

Retrieve the color associated with a specific achievement level.

Parameters:

Name Type Description Default
achievement_level int

The level of achievement to get the color for.

required

Returns:

Type Description
Optional[str]

Optional[str]: The color code associated with the given level, or None if not found.

Source code in dcm/dcm_core.py
390
391
392
393
394
395
396
397
398
399
400
401
402
403
def get_level_color(self, achievement_level: int) -> Optional[str]:
    """
    Retrieve the color associated with a specific achievement level.

    Args:
        achievement_level (int): The level of achievement to get the color for.

    Returns:
        Optional[str]: The color code associated with the given level, or None if not found.
    """
    for level in self.levels:
        if level.level == achievement_level:
            return level.color_code
    return None

get_symmetry_spec()

Get the symmetry specification from the ring_specs, ensuring only one spec has the symmetry mode set.

Returns:

Type Description
Tuple[str, str]

Tuple[str, str]: A pair of symmetry mode and the level it is applied to.

Raises:

Type Description
ValueError

If multiple RingSpecs have a symmetry mode set.

Source code in dcm/dcm_core.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
def get_symmetry_spec(self) -> Tuple[str, str]:
    """
    Get the symmetry specification from the ring_specs, ensuring only one spec has the symmetry mode set.

    Returns:
        Tuple[str, str]: A pair of symmetry mode and the level it is applied to.

    Raises:
        ValueError: If multiple RingSpecs have a symmetry mode set.
    """

    symmetry_modes = [
        (level, spec.symmetry_mode)
        for level, spec in self.ring_specs.items()
        if spec.symmetry_mode
    ]
    if len(symmetry_modes) > 1:
        raise ValueError("Symmetry mode set for multiple ring specs.")
    elif len(symmetry_modes) == 1:
        level, mode = symmetry_modes[0]
    else:
        # Default to "count" and CompetenceFacet level if no spec is given
        level, mode = ("facet", "count")
    if not mode in ["count", "score", "time"]:
        raise ValueError(
            f"Invalid symmetry mode {mode} - must be count, score or time"
        )
    return (level, mode)

lookup_by_path(path, lenient=True)

Look up and return a competence element (tree,aspect of facet) based on the given path.

The path is expected to be in the format "tree_id/aspect_id/facet_id". This method parses the path and retrieves the corresponding competence aspect or facet.

Parameters:

Name Type Description Default
path str

The path in the format "tree_id/aspect_id/facet_id".

required
lenient(bool)

if not lenient raise Exceptions for invalid paths and ids

required

Returns: Optional[CompetenceElement]: The competence aspect or facet corresponding to the given path.

Source code in dcm/dcm_core.py
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
def lookup_by_path(
    self, path: str, lenient: bool = True
) -> Optional[CompetenceElement]:
    """
    Look up and return a competence element (tree,aspect of facet)
    based on the given path.

    The path is expected to be in the format "tree_id/aspect_id/facet_id".
    This method parses the path and retrieves the corresponding competence aspect or facet.

    Args:
        path (str): The path in the format "tree_id/aspect_id/facet_id".

        lenient(bool): if not lenient raise Exceptions for invalid paths and ids
    Returns:
        Optional[CompetenceElement]: The competence aspect or facet corresponding to the given path.
    """

    def handle_error(msg):
        if not lenient:
            raise ValueError(msg)

    element = None
    if path not in self.elements_by_path:
        msg = f"invalid path {path}"
        if not lenient:
            raise ValueError(msg)
    else:
        element = self.elements_by_path.get(path)
    return element

set_symmetry_mode(symmetry_level, symmetry_mode)

Sets a new symmetry mode for a specified level in the competence tree. It resets the symmetry mode for all levels to None and then applies the new symmetry mode to the specified level. This method ensures that only one level has a symmetry mode set at any given time.

Parameters:

Name Type Description Default
symmetry_level(str)

The level to apply the new symmetry mode. Valid levels include "tree", "aspect", "area", and "facet".

required
symmetry_mode str

The symmetry mode to set for the specified level. Valid modes are "count", "score", and "time".

required
Source code in dcm/dcm_core.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
def set_symmetry_mode(self, symmetry_level: str, symmetry_mode: str):
    """
     Sets a new symmetry mode for a specified level
     in the competence tree. It resets the symmetry
     mode for all levels to None and then
     applies the new symmetry mode to the
     specified level. This method ensures that
     only one level has a symmetry mode set at any given time.

    Args:
        symmetry_level(str): The level to apply the new symmetry mode. Valid levels include "tree", "aspect", "area", and "facet".
        symmetry_mode (str): The symmetry mode to set for the specified level. Valid modes are "count", "score", and "time".
    """
    # reset all ring specs
    for rl in ["tree", "aspect", "area", "facet"]:
        self.ring_specs[rl].symmetry_mode = None
    if symmetry_level in self.ring_specs:
        self.ring_specs[symmetry_level].symmetry_mode = symmetry_mode

to_pretty_json()

Converts the CompetenceTree object to a pretty JSON string, handling null values.

Source code in dcm/dcm_core.py
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
def to_pretty_json(self):
    """
    Converts the CompetenceTree object to a pretty JSON string, handling null values.
    """
    json_str = self.to_json()
    json_dict = json.loads(json_str)

    def remove_none_values(data):
        """
        Recursively removes keys with None values from a dictionary, list, or nested structure.
        """
        if isinstance(data, dict):
            return {
                k: remove_none_values(v) for k, v in data.items() if v is not None
            }
        elif isinstance(data, list):
            return [remove_none_values(item) for item in data]
        return data

    none_free_dict = remove_none_values(json_dict)
    null_free_json_str = json.dumps(none_free_dict, indent=2)
    return null_free_json_str

update_paths()

update my paths

Source code in dcm/dcm_core.py
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
def update_paths(self):
    """
    update my paths
    """
    self.level_names = ["tree", "aspect", "area", "facet"]
    self.level_attr_names = [None, "aspects", "areas", "facets"]
    self.total_elements = {"tree": 1, "aspects": 0, "areas": 0, "facets": 0}
    self.elements_by_level = {
        "tree": [self],
        "aspect": [],
        "area": [],
        "facet": [],
    }  # Reset for re-calculation

    self.path = self.id
    self.total_levels = 1
    self.elements_by_path = {self.path: self}
    # Loop through each competence aspect and set their paths and parent references
    for aspect in self.aspects:
        aspect.competence_tree = self
        aspect.path = f"{self.id}/{aspect.id}"
        self.elements_by_path[aspect.path] = aspect
        self.total_elements["aspects"] = self.total_elements["aspects"] + 1
        self.total_levels = 2
        self.elements_by_level["aspect"].append(aspect)
        for area in aspect.areas:
            self.total_levels = 3
            area.competence_tree = self
            area.aspect = aspect
            area.path = f"{self.id}/{aspect.id}/{area.id}"
            self.elements_by_path[area.path] = area
            self.total_elements["areas"] = self.total_elements["areas"] + 1
            self.elements_by_level["area"].append(area)
            for facet in area.facets:
                self.total_levels = 4
                facet.competence_tree = self
                facet.area = area
                facet.path = f"{self.id}/{aspect.id}/{area.id}/{facet.id}"
                self.elements_by_path[facet.path] = facet
                self.total_elements["facets"] = self.total_elements["facets"] + 1
                self.elements_by_level["facet"].append(facet)

DynamicCompetenceMap

a visualization of a competence map

Source code in dcm/dcm_core.py
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
class DynamicCompetenceMap:
    """
    a visualization of a competence map
    """

    def __init__(self, competence_tree: CompetenceTree, svg: SVG = None):
        """
        constructor
        """
        self.competence_tree = competence_tree
        self.svg = svg

    @property
    def main_id(self):
        main_id = self.competence_tree.id
        return main_id

    @classmethod
    def examples_path(cls) -> str:
        # the root directory (default: examples)
        path = os.path.join(os.path.dirname(__file__), "../dcm_examples")
        path = os.path.abspath(path)
        return path

    @classmethod
    def get_example_dcm_definitions(
        cls,
        markup: str = "json",
        required_keys: Optional[Tuple] = None,
        as_text: bool = True,
    ) -> dict:
        """
        Retrieve example Dynamic Competence Map (DCM) definitions from files in the specified markup format (either JSON or YAML).

        Args:
            markup (str): The markup format of the input files. Defaults to 'json'. Supported values are 'json' and 'yaml'.
            required_keys (Optional[Tuple]): A tuple of keys required to validate the data. If not provided, all keys will be considered valid.
            as_text (bool): If True, returns the file content as text; if False, returns parsed data. Defaults to True.

        Returns:
            dict: A dictionary where each key is the prefix of the file name and the value is the file content as text or parsed data, depending on the value of 'as_text'.

        Raises:
            Exception: If there's an error in reading or parsing the file, or if the file does not meet the required validation criteria.
        """
        example_dcm_defs = {}
        file_ext = f".{markup}"
        examples_path = cls.examples_path()
        for dirpath, _dirnames, filenames in os.walk(examples_path):
            for filename in filenames:
                if filename.endswith(file_ext):
                    filepath = os.path.join(dirpath, filename)
                    with open(filepath, "r") as definition_file:
                        file_prefix = filename.replace(file_ext, "")
                        definition_text = definition_file.read()
                        try:
                            definition_data = cls.parse_markup(definition_text, markup)
                            if cls.is_valid_definition(definition_data, required_keys):
                                if as_text:
                                    example_dcm_defs[file_prefix] = definition_text
                                else:
                                    example_dcm_defs[file_prefix] = definition_data
                        except Exception as ex:
                            cls.handle_markup_issue(
                                filename, definition_text, ex, markup
                            )
        return example_dcm_defs

    @classmethod
    def parse_markup(cls, text: str, markup: str) -> Union[dict, list]:
        """
        Parse the given text as JSON or YAML based on the specified markup type.

        Args:
            text (str): The string content to be parsed.
            markup (str): The type of markup to use for parsing. Supported values are 'json' and 'yaml'.

        Returns:
            Union[dict, list]: The parsed data, which can be either a dictionary or a list, depending on the content.

        Raises:
            ValueError: If an unsupported markup format is specified.
        """
        if markup == "json":
            data = json.loads(text)
            return data
        elif markup == "yaml":
            data = yaml.safe_load(text)
            return data
        else:
            raise ValueError(f"Unsupported markup format: {markup}")

    @classmethod
    def handle_markup_issue(cls, name: str, definition_string: str, ex, markup: str):
        if isinstance(ex, JSONDecodeError):
            lines = definition_string.splitlines()  # Split the string into lines
            err_line = lines[ex.lineno - 1]  # JSONDecodeError gives 1-based lineno
            pointer = (
                " " * (ex.colno - 1) + "^"
            )  # Create a pointer string to indicate the error position
            error_message = (
                f"{name}:JSON parsing error on line {ex.lineno} column {ex.colno}:\n"
                f"{err_line}\n"
                f"{pointer}\n"
                f"{ex.msg}"
            )
            raise ValueError(error_message)  # Raise a new exception with this message
        else:
            error_message = f"error in {name}: {str(ex)}"
            raise ValueError(error_message)

    @classmethod
    def is_valid_definition(cls, definition_data, required_keys: Tuple):
        return all(key in definition_data for key in required_keys)

    @classmethod
    def get_examples(cls, content_class=CompetenceTree, markup: str = "json") -> dict:
        examples = {}
        for name, definition_string in cls.get_example_dcm_definitions(
            required_keys=content_class.required_keys(), markup=markup
        ).items():
            example = cls.from_definition_string(
                name, definition_string, content_class, markup=markup
            )
            # check the type of the example
            example_id = example.main_id
            examples[example_id] = example
        return examples

    @classmethod
    def from_definition_string(
        cls,
        name: str,
        definition_string: str,
        content_class,
        markup: str = "json",
        debug: bool = False,
    ) -> Any:
        """
        Load a DynamicCompetenceMap or Learner instance from a definition string (either JSON or YAML).

        Args:
            name (str): A name identifier for the data source.
            definition_string (str): The string content of the definition.
            content_class (dataclass_json): The class which will be instantiated with the parsed data.
            markup (str): The markup format of the data. Defaults to 'json'. Supported values are 'json' and 'yaml'.
            debug(bool): if True supply a JSON dump of the data in /tmp/{name}.json
        Returns:
            DynamicCompetenceMap: An instance of DynamicCompetenceMap loaded with the parsed data.

        Raises:
            ValueError: If there's an error in parsing the data.
        """
        try:
            data = cls.parse_markup(definition_string, markup)
            if debug:
                # Save the parsed data to a JSON file in /tmp directory
                debug_file_path = os.path.join("/tmp", f"{name}.json")
                with open(debug_file_path, "w") as debug_file:
                    json.dump(data, debug_file, indent=2, default=str)
            content = content_class.from_dict(data)
            if isinstance(content, CompetenceTree):
                return DynamicCompetenceMap(content)
            else:
                return content
        except Exception as ex:
            cls.handle_markup_issue(name, definition_string, ex, markup)

__init__(competence_tree, svg=None)

constructor

Source code in dcm/dcm_core.py
619
620
621
622
623
624
def __init__(self, competence_tree: CompetenceTree, svg: SVG = None):
    """
    constructor
    """
    self.competence_tree = competence_tree
    self.svg = svg

from_definition_string(name, definition_string, content_class, markup='json', debug=False) classmethod

Load a DynamicCompetenceMap or Learner instance from a definition string (either JSON or YAML).

Parameters:

Name Type Description Default
name str

A name identifier for the data source.

required
definition_string str

The string content of the definition.

required
content_class dataclass_json

The class which will be instantiated with the parsed data.

required
markup str

The markup format of the data. Defaults to 'json'. Supported values are 'json' and 'yaml'.

'json'
debug(bool)

if True supply a JSON dump of the data in /tmp/{name}.json

required

Returns: DynamicCompetenceMap: An instance of DynamicCompetenceMap loaded with the parsed data.

Raises:

Type Description
ValueError

If there's an error in parsing the data.

Source code in dcm/dcm_core.py
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
@classmethod
def from_definition_string(
    cls,
    name: str,
    definition_string: str,
    content_class,
    markup: str = "json",
    debug: bool = False,
) -> Any:
    """
    Load a DynamicCompetenceMap or Learner instance from a definition string (either JSON or YAML).

    Args:
        name (str): A name identifier for the data source.
        definition_string (str): The string content of the definition.
        content_class (dataclass_json): The class which will be instantiated with the parsed data.
        markup (str): The markup format of the data. Defaults to 'json'. Supported values are 'json' and 'yaml'.
        debug(bool): if True supply a JSON dump of the data in /tmp/{name}.json
    Returns:
        DynamicCompetenceMap: An instance of DynamicCompetenceMap loaded with the parsed data.

    Raises:
        ValueError: If there's an error in parsing the data.
    """
    try:
        data = cls.parse_markup(definition_string, markup)
        if debug:
            # Save the parsed data to a JSON file in /tmp directory
            debug_file_path = os.path.join("/tmp", f"{name}.json")
            with open(debug_file_path, "w") as debug_file:
                json.dump(data, debug_file, indent=2, default=str)
        content = content_class.from_dict(data)
        if isinstance(content, CompetenceTree):
            return DynamicCompetenceMap(content)
        else:
            return content
    except Exception as ex:
        cls.handle_markup_issue(name, definition_string, ex, markup)

get_example_dcm_definitions(markup='json', required_keys=None, as_text=True) classmethod

Retrieve example Dynamic Competence Map (DCM) definitions from files in the specified markup format (either JSON or YAML).

Parameters:

Name Type Description Default
markup str

The markup format of the input files. Defaults to 'json'. Supported values are 'json' and 'yaml'.

'json'
required_keys Optional[Tuple]

A tuple of keys required to validate the data. If not provided, all keys will be considered valid.

None
as_text bool

If True, returns the file content as text; if False, returns parsed data. Defaults to True.

True

Returns:

Name Type Description
dict dict

A dictionary where each key is the prefix of the file name and the value is the file content as text or parsed data, depending on the value of 'as_text'.

Raises:

Type Description
Exception

If there's an error in reading or parsing the file, or if the file does not meet the required validation criteria.

Source code in dcm/dcm_core.py
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
@classmethod
def get_example_dcm_definitions(
    cls,
    markup: str = "json",
    required_keys: Optional[Tuple] = None,
    as_text: bool = True,
) -> dict:
    """
    Retrieve example Dynamic Competence Map (DCM) definitions from files in the specified markup format (either JSON or YAML).

    Args:
        markup (str): The markup format of the input files. Defaults to 'json'. Supported values are 'json' and 'yaml'.
        required_keys (Optional[Tuple]): A tuple of keys required to validate the data. If not provided, all keys will be considered valid.
        as_text (bool): If True, returns the file content as text; if False, returns parsed data. Defaults to True.

    Returns:
        dict: A dictionary where each key is the prefix of the file name and the value is the file content as text or parsed data, depending on the value of 'as_text'.

    Raises:
        Exception: If there's an error in reading or parsing the file, or if the file does not meet the required validation criteria.
    """
    example_dcm_defs = {}
    file_ext = f".{markup}"
    examples_path = cls.examples_path()
    for dirpath, _dirnames, filenames in os.walk(examples_path):
        for filename in filenames:
            if filename.endswith(file_ext):
                filepath = os.path.join(dirpath, filename)
                with open(filepath, "r") as definition_file:
                    file_prefix = filename.replace(file_ext, "")
                    definition_text = definition_file.read()
                    try:
                        definition_data = cls.parse_markup(definition_text, markup)
                        if cls.is_valid_definition(definition_data, required_keys):
                            if as_text:
                                example_dcm_defs[file_prefix] = definition_text
                            else:
                                example_dcm_defs[file_prefix] = definition_data
                    except Exception as ex:
                        cls.handle_markup_issue(
                            filename, definition_text, ex, markup
                        )
    return example_dcm_defs

parse_markup(text, markup) classmethod

Parse the given text as JSON or YAML based on the specified markup type.

Parameters:

Name Type Description Default
text str

The string content to be parsed.

required
markup str

The type of markup to use for parsing. Supported values are 'json' and 'yaml'.

required

Returns:

Type Description
Union[dict, list]

Union[dict, list]: The parsed data, which can be either a dictionary or a list, depending on the content.

Raises:

Type Description
ValueError

If an unsupported markup format is specified.

Source code in dcm/dcm_core.py
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
@classmethod
def parse_markup(cls, text: str, markup: str) -> Union[dict, list]:
    """
    Parse the given text as JSON or YAML based on the specified markup type.

    Args:
        text (str): The string content to be parsed.
        markup (str): The type of markup to use for parsing. Supported values are 'json' and 'yaml'.

    Returns:
        Union[dict, list]: The parsed data, which can be either a dictionary or a list, depending on the content.

    Raises:
        ValueError: If an unsupported markup format is specified.
    """
    if markup == "json":
        data = json.loads(text)
        return data
    elif markup == "yaml":
        data = yaml.safe_load(text)
        return data
    else:
        raise ValueError(f"Unsupported markup format: {markup}")

Learner

A learner with achievements. Attributes: learner_id (str): Identifier for the learner. achievements (Dict[str, List[Achievement]]): A dictionary where each key is a competence element identifier and the value is a list of Achievement instances for that tree.

Source code in dcm/dcm_core.py
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
@lod_storable
class Learner:
    """
    A learner with achievements.
    Attributes:
        learner_id (str): Identifier for the learner.
        achievements (Dict[str, List[Achievement]]):
            A dictionary where each key is a competence element identifier
            and the value is a list of Achievement instances for that tree.
    """

    learner_id: str
    achievements: Optional[List[Achievement]] = field(default=None)

    def __post_init__(self):
        self.achievements_by_path = {}
        self.achievement_indices_by_path = {}
        if self.achievements:
            for index, achievement in enumerate(self.achievements):
                self.achievements_by_path[achievement.path] = achievement
                self.achievement_indices_by_path[achievement.path] = index

    @classmethod
    def required_keys(cls):
        keys = {"achievements"}
        return keys

    @property
    def main_id(self):
        main_id = self.learner_id
        return main_id

    @property
    def file_name(self):
        file_name = slugify(
            self.learner_id, lowercase=False, regex_pattern=r"[^\w\s\-]"
        )
        return file_name

    @property
    def most_recent_achievement_iso_date(self) -> Optional[str]:
        """
        Get the most recent achievement date in ISO format.

        Returns:
            Optional[str]: The ISO date string of the most recent achievement, or None if there are no achievements.
        """
        if not self.achievements:
            return None
        dates = [
            achievement.date_assessed_iso
            for achievement in self.achievements
            if achievement.date_assessed_iso
        ]
        if not dates:
            return None
        # Parse the ISO dates and return the most recent one
        most_recent_date = max(datetime.fromisoformat(date) for date in dates)
        return most_recent_date.isoformat()

    def get_achievement_index(self, path) -> int:
        a_index = self.achievement_indices_by_path.get(path)
        return a_index

    def add_achievement(self, new_achievement):
        """
        Add a new achievement for the learner.
        """
        self.achievements.append(new_achievement)
        index = len(self.achievements) - 1
        self.achievements_by_path[new_achievement.path] = new_achievement
        self.achievement_indices_by_path[new_achievement.path] = index

    def get_competence_tree_ids(self) -> List[str]:
        """
        Get all unique competence tree IDs of my achievements.

        Returns:
            List[str]: A list of unique competence tree IDs.
        """
        # Assuming that the learner's achievements are stored in a list called self.achievements
        # You can modify this part according to your actual data structure.

        # Create a set to store unique competence tree IDs
        unique_tree_ids = set()

        # Iterate through the learner's achievements
        for achievement in self.achievements:
            # Assuming each achievement has a tree_id attribute
            tree_id = achievement.tree_id

            # Add the tree_id to the set
            unique_tree_ids.add(tree_id)

        # Convert the set to a list and return
        return list(unique_tree_ids)

most_recent_achievement_iso_date: Optional[str] property

Get the most recent achievement date in ISO format.

Returns:

Type Description
Optional[str]

Optional[str]: The ISO date string of the most recent achievement, or None if there are no achievements.

add_achievement(new_achievement)

Add a new achievement for the learner.

Source code in dcm/dcm_core.py
580
581
582
583
584
585
586
587
def add_achievement(self, new_achievement):
    """
    Add a new achievement for the learner.
    """
    self.achievements.append(new_achievement)
    index = len(self.achievements) - 1
    self.achievements_by_path[new_achievement.path] = new_achievement
    self.achievement_indices_by_path[new_achievement.path] = index

get_competence_tree_ids()

Get all unique competence tree IDs of my achievements.

Returns:

Type Description
List[str]

List[str]: A list of unique competence tree IDs.

Source code in dcm/dcm_core.py
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
def get_competence_tree_ids(self) -> List[str]:
    """
    Get all unique competence tree IDs of my achievements.

    Returns:
        List[str]: A list of unique competence tree IDs.
    """
    # Assuming that the learner's achievements are stored in a list called self.achievements
    # You can modify this part according to your actual data structure.

    # Create a set to store unique competence tree IDs
    unique_tree_ids = set()

    # Iterate through the learner's achievements
    for achievement in self.achievements:
        # Assuming each achievement has a tree_id attribute
        tree_id = achievement.tree_id

        # Add the tree_id to the set
        unique_tree_ids.add(tree_id)

    # Convert the set to a list and return
    return list(unique_tree_ids)

RingSpec dataclass

Specification of rings of the donut chart.

Attributes:

Name Type Description
text_mode Optional[str]

The mode of text display on the ring. Default is None.

inner_ratio Optional[float]

The inner radius of the ring, relative to the chart size.

outer_ratio Optional[float]

The outer radius of the ring, relative to the chart size.

symmetry_mode Optional[str]

Specifies the symmetry mode for the ring. Supports "count", "time", "score" to determine how the ring segments are balanced. Default is None.

Source code in dcm/dcm_core.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@dataclass_json
@dataclass
class RingSpec:
    """
    Specification of rings of the donut chart.

    Attributes:
        text_mode (Optional[str]): The mode of text display on the ring. Default is None.
        inner_ratio (Optional[float]): The inner radius of the ring, relative to the chart size.
        outer_ratio (Optional[float]): The outer radius of the ring, relative to the chart size.
        symmetry_mode (Optional[str]): Specifies the symmetry mode for the ring. Supports "count", "time", "score" to determine how the ring segments are balanced. Default is None.
    """

    text_mode: Optional[str] = "empty"
    inner_ratio: Optional[float] = None
    outer_ratio: Optional[float] = None
    levels_visible: Optional[bool] = False
    symmetry_mode: Optional[str] = None

    @property
    def empty(self) -> bool:
        empty = (
            self.inner_ratio is None
            or self.outer_ratio is None
            or (self.inner_ratio + self.outer_ratio) == 0.0
        )
        return empty

dcm_web

Created on 2024-01-26

@author: wf

RingSpecView

show a single ring specification

Source code in dcm/dcm_web.py
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 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
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
class RingSpecView:
    """
    show a single ring specification
    """

    def __init__(
        self, parent, ring_level: str, ringspec: RingSpec, throttle: float = 0.1
    ):
        """
        construct me
        """
        self.parent = parent
        self.ringspec = ringspec
        self.ring_level = ring_level
        self.ringspec = ringspec
        self.change_enabled = False
        self.throttle = throttle
        self.setup_ui()

    def setup_ui(self):
        """
        setup the user interface
        """
        with self.parent.grid:
            selection = ["empty", "curved", "horizontal", "angled"]
            self.text_mode_select = self.parent.webserver.add_select(
                self.ring_level,
                selection,
                value=self.ringspec.text_mode,
                on_change=self.on_text_mode_change,
            )
            self.level_visible_checkbox = ui.checkbox("level").on(
                "click", self.on_level_visible_change
            )
            self.inner_ratio_slider = (
                ui.slider(min=0, max=1.0, step=0.01, value=self.ringspec.inner_ratio)
                .props("label-always")
                .on(
                    "update:model-value",
                    self.on_inner_ratio_change,
                    throttle=self.throttle,
                )
            )
            self.outer_ratio_slider = (
                ui.slider(min=0, max=1.0, step=0.01, value=self.ringspec.outer_ratio)
                .props("label-always")
                .on(
                    "update:model-value",
                    self.on_outer_ratio_change,
                    throttle=self.throttle,
                )
            )

    def update(self, rs: RingSpec):
        """
        update the ring specification to be modified by this ui
        """
        self.ringspec = rs
        self.change_enabled = False
        self.text_mode_select.value = rs.text_mode
        self.inner_ratio_slider.value = round(rs.inner_ratio, 2)
        self.outer_ratio_slider.value = round(rs.outer_ratio, 2)
        self.change_enabled = True

    def on_level_visible_change(self):
        """ """
        self.ringspec.levels_visible = self.level_visible_checkbox.value
        self.parent.on_change()

    def on_inner_ratio_change(self, gev: GenericEventArguments):
        """
        Handle changes in the inner_ratio slider.
        """
        if self.change_enabled:
            self.ringspec.inner_ratio = gev.args
            self.parent.on_change()

    def on_outer_ratio_change(self, gev: GenericEventArguments):
        """
        Handle changes in the outer_ratio slider.
        """
        if self.change_enabled:
            self.ringspec.outer_ratio = gev.args
            self.parent.on_change()

    async def on_text_mode_change(self, args):
        """
        handle changes in the text_mode
        """
        # ignore changes if change_enabled is not active
        if not self.change_enabled:
            return
        new_text_mode = args.value
        if new_text_mode != self.ringspec.text_mode:
            self.ringspec.text_mode = new_text_mode
        self.parent.on_change()

__init__(parent, ring_level, ringspec, throttle=0.1)

construct me

Source code in dcm/dcm_web.py
18
19
20
21
22
23
24
25
26
27
28
29
30
def __init__(
    self, parent, ring_level: str, ringspec: RingSpec, throttle: float = 0.1
):
    """
    construct me
    """
    self.parent = parent
    self.ringspec = ringspec
    self.ring_level = ring_level
    self.ringspec = ringspec
    self.change_enabled = False
    self.throttle = throttle
    self.setup_ui()

on_inner_ratio_change(gev)

Handle changes in the inner_ratio slider.

Source code in dcm/dcm_web.py
82
83
84
85
86
87
88
def on_inner_ratio_change(self, gev: GenericEventArguments):
    """
    Handle changes in the inner_ratio slider.
    """
    if self.change_enabled:
        self.ringspec.inner_ratio = gev.args
        self.parent.on_change()

on_level_visible_change()

Source code in dcm/dcm_web.py
77
78
79
80
def on_level_visible_change(self):
    """ """
    self.ringspec.levels_visible = self.level_visible_checkbox.value
    self.parent.on_change()

on_outer_ratio_change(gev)

Handle changes in the outer_ratio slider.

Source code in dcm/dcm_web.py
90
91
92
93
94
95
96
def on_outer_ratio_change(self, gev: GenericEventArguments):
    """
    Handle changes in the outer_ratio slider.
    """
    if self.change_enabled:
        self.ringspec.outer_ratio = gev.args
        self.parent.on_change()

on_text_mode_change(args) async

handle changes in the text_mode

Source code in dcm/dcm_web.py
 98
 99
100
101
102
103
104
105
106
107
108
async def on_text_mode_change(self, args):
    """
    handle changes in the text_mode
    """
    # ignore changes if change_enabled is not active
    if not self.change_enabled:
        return
    new_text_mode = args.value
    if new_text_mode != self.ringspec.text_mode:
        self.ringspec.text_mode = new_text_mode
    self.parent.on_change()

setup_ui()

setup the user interface

Source code in dcm/dcm_web.py
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
def setup_ui(self):
    """
    setup the user interface
    """
    with self.parent.grid:
        selection = ["empty", "curved", "horizontal", "angled"]
        self.text_mode_select = self.parent.webserver.add_select(
            self.ring_level,
            selection,
            value=self.ringspec.text_mode,
            on_change=self.on_text_mode_change,
        )
        self.level_visible_checkbox = ui.checkbox("level").on(
            "click", self.on_level_visible_change
        )
        self.inner_ratio_slider = (
            ui.slider(min=0, max=1.0, step=0.01, value=self.ringspec.inner_ratio)
            .props("label-always")
            .on(
                "update:model-value",
                self.on_inner_ratio_change,
                throttle=self.throttle,
            )
        )
        self.outer_ratio_slider = (
            ui.slider(min=0, max=1.0, step=0.01, value=self.ringspec.outer_ratio)
            .props("label-always")
            .on(
                "update:model-value",
                self.on_outer_ratio_change,
                throttle=self.throttle,
            )
        )

update(rs)

update the ring specification to be modified by this ui

Source code in dcm/dcm_web.py
66
67
68
69
70
71
72
73
74
75
def update(self, rs: RingSpec):
    """
    update the ring specification to be modified by this ui
    """
    self.ringspec = rs
    self.change_enabled = False
    self.text_mode_select.value = rs.text_mode
    self.inner_ratio_slider.value = round(rs.inner_ratio, 2)
    self.outer_ratio_slider.value = round(rs.outer_ratio, 2)
    self.change_enabled = True

RingSpecsView

show a list of ringspecs

Source code in dcm/dcm_web.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
class RingSpecsView:
    """
    show a list of ringspecs
    """

    def __init__(self, webserver):
        self.webserver = webserver
        self.ct = None
        # ringspec views
        self.rsv = {}
        self.setup_ui()
        self.symmetry_mode = None
        self.symmetry_level = None

    def setup_ui(self):
        """
        setup the user interface
        """
        with ui.expansion("ring specs", icon="settings") as self.expansion:
            with ui.expansion("symmetry", icon="balance"):
                with ui.row():
                    self.symmetry_mode_radio = ui.radio(
                        ["count", "time", "score"], on_change=self.on_symmetry_change
                    ).props("inline")
                with ui.row():
                    self.symmetry_level_radio = ui.radio(
                        ["aspect", "area", "facet"], on_change=self.on_symmetry_change
                    ).props("inline")
            with ui.grid(columns=2, rows=8) as self.grid:
                inner_ratio = 0
                outer_ratio = 1 / 7
                levels = ["tree", "aspect", "area", "facet"]
                for rl in levels:
                    rs = RingSpec(
                        text_mode="empty",
                        inner_ratio=round(inner_ratio, 2),
                        outer_ratio=round(outer_ratio, 2),
                    )
                    self.rsv[rl] = RingSpecView(self, rl, rs)
                    inner_ratio = outer_ratio
                    outer_ratio = outer_ratio + 2 / 7

    def update_rings(self, ct: CompetenceTree):
        """
        update the ring specifications based on the given competence tree
        """
        self.symmetry_level, self.symmetry_mode = ct.get_symmetry_spec()
        self.symmetry_level_radio.value = self.symmetry_level
        self.symmetry_mode_radio.value = self.symmetry_mode
        # set ct after changing radio buttions
        self.ct = ct
        for rl in ["tree", "aspect", "area", "facet"]:
            self.rsv[rl].update(ct.ring_specs[rl])

    def on_symmetry_change(self, args):
        """
        handle symmetry changes
        """
        if self.ct:
            # get compentency tree symmetry settings
            # and the current ui radio button settings
            ct_symmetry_level, ct_symmetry_mode = self.ct.get_symmetry_spec()
            self.symmetry_level = self.symmetry_level_radio.value
            self.symmetry_mode = self.symmetry_mode_radio.value
            # check whether the radio values are different from the ct values
            if (
                ct_symmetry_level != self.symmetry_level
                or ct_symmetry_mode != self.symmetry_mode
            ):
                self.ct.set_symmetry_mode(self.symmetry_level, self.symmetry_mode)
            pass
        self.on_change()

    def on_change(self):
        """
        if a ring spec trigger update
        """
        self.webserver.on_update_ringspecs()

on_change()

if a ring spec trigger update

Source code in dcm/dcm_web.py
184
185
186
187
188
def on_change(self):
    """
    if a ring spec trigger update
    """
    self.webserver.on_update_ringspecs()

on_symmetry_change(args)

handle symmetry changes

Source code in dcm/dcm_web.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def on_symmetry_change(self, args):
    """
    handle symmetry changes
    """
    if self.ct:
        # get compentency tree symmetry settings
        # and the current ui radio button settings
        ct_symmetry_level, ct_symmetry_mode = self.ct.get_symmetry_spec()
        self.symmetry_level = self.symmetry_level_radio.value
        self.symmetry_mode = self.symmetry_mode_radio.value
        # check whether the radio values are different from the ct values
        if (
            ct_symmetry_level != self.symmetry_level
            or ct_symmetry_mode != self.symmetry_mode
        ):
            self.ct.set_symmetry_mode(self.symmetry_level, self.symmetry_mode)
        pass
    self.on_change()

setup_ui()

setup the user interface

Source code in dcm/dcm_web.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
def setup_ui(self):
    """
    setup the user interface
    """
    with ui.expansion("ring specs", icon="settings") as self.expansion:
        with ui.expansion("symmetry", icon="balance"):
            with ui.row():
                self.symmetry_mode_radio = ui.radio(
                    ["count", "time", "score"], on_change=self.on_symmetry_change
                ).props("inline")
            with ui.row():
                self.symmetry_level_radio = ui.radio(
                    ["aspect", "area", "facet"], on_change=self.on_symmetry_change
                ).props("inline")
        with ui.grid(columns=2, rows=8) as self.grid:
            inner_ratio = 0
            outer_ratio = 1 / 7
            levels = ["tree", "aspect", "area", "facet"]
            for rl in levels:
                rs = RingSpec(
                    text_mode="empty",
                    inner_ratio=round(inner_ratio, 2),
                    outer_ratio=round(outer_ratio, 2),
                )
                self.rsv[rl] = RingSpecView(self, rl, rs)
                inner_ratio = outer_ratio
                outer_ratio = outer_ratio + 2 / 7

update_rings(ct)

update the ring specifications based on the given competence tree

Source code in dcm/dcm_web.py
153
154
155
156
157
158
159
160
161
162
163
def update_rings(self, ct: CompetenceTree):
    """
    update the ring specifications based on the given competence tree
    """
    self.symmetry_level, self.symmetry_mode = ct.get_symmetry_spec()
    self.symmetry_level_radio.value = self.symmetry_level
    self.symmetry_mode_radio.value = self.symmetry_mode
    # set ct after changing radio buttions
    self.ct = ct
    for rl in ["tree", "aspect", "area", "facet"]:
        self.rsv[rl].update(ct.ring_specs[rl])

dcm_webserver

Created on 2023-11-06

@author: wf

DcmSolution

Bases: InputWebSolution

the Dynamic Competence Map solution

Source code in dcm/dcm_webserver.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
class DcmSolution(InputWebSolution):
    """
    the Dynamic Competence Map solution
    """

    def __init__(self, webserver: DynamicCompentenceMapWebServer, client: Client):
        """
        Initialize the solution

        Calls the constructor of the base solution
        Args:
            webserver (DynamicCompotenceMapWebServer): The webserver instance associated with this context.
            client (Client): The client instance this context is associated with.
        """
        super().__init__(webserver, client)  # Call to the superclass constructor
        self.dcm = None
        self.learner = None
        self.assessment = None
        self.text_mode = "empty"

    def get_basename_without_extension(self, url) -> str:
        # Parse the URL to get the path component
        path = urlparse(url).path
        # Extract the base name (e.g., "example.html" from "/dir/example.html")
        basename = os.path.basename(path)
        # Split the base name and extension and return just the base name
        return os.path.splitext(basename)[0]

    def save_session_state(self) -> None:
        """
        Save the current session state to app.storage.user.
        """
        learner_id = self.learner.learner_id if self.learner else None
        app.storage.user["learner_id"] = learner_id
        app.storage.user["assessment"] = self.assessment is not None

    def get_learner_file_path(self, learner_slug: str) -> str:
        """
        Get the file path for a learner's JSON file based on the learner's slug.

        Args:
            learner_slug (str): The unique slug of the learner.

        Returns:
            str: The file path for the learner's JSON file.
        """
        return os.path.join(self.config.storage_path, f"{learner_slug}.json")

    def load_learner(self, learner_slug: str) -> None:
        """
        Load a learner from a JSON file based on the learner's slug.

        Args:
            learner_slug (str): The unique slug of the learner.

        Raises:
            FileNotFoundError: If the learner file does not exist.
        """
        learner_file = self.get_learner_file_path(learner_slug)
        if not os.path.exists(learner_file):
            raise FileNotFoundError(f"Learner file not found: {learner_file}")
        self.learner = Learner.load_from_json_file(learner_file)
        self.save_session_state()

    async def render(self, _click_args=None):
        """
        Renders the json content as an SVG visualization

        Args:
            click_args (object): The click event arguments.
        """
        try:
            input_source = self.input
            if input_source:
                name = self.get_basename_without_extension(input_source)
                with self.content_div:
                    ui.notify(f"rendering {name}")
                definition = self.do_read_input(input_source)
                # Determine the format based on the file extension
                markup = "json" if input_source.endswith(".json") else "yaml"
                if "learner_id" in definition:
                    content_class = Learner
                else:
                    content_class = CompetenceTree
                item = DynamicCompetenceMap.from_definition_string(
                    name, definition, content_class=content_class, markup=markup
                )
                self.render_item(item)
        except Exception as ex:
            self.handle_exception(ex)

    def render_item(self, item):
        if isinstance(item, DynamicCompetenceMap):
            self.render_dcm(item)
        else:
            self.learner = item
            self.save_session_state()
            self.assess(item)

    def render_dcm(
        self,
        dcm,
        learner: Learner = None,
        selected_paths: List = [],
        clear_assessment: bool = True,
    ):
        """
        render the dynamic competence map

        Args:
            dcm(DynamicCompetenceMap)
            selected_paths (List, optional): A list of paths that should be highlighted
            in the SVG. These paths typically represent specific competencies or
            achievements. Defaults to an empty list.

        """
        try:
            if clear_assessment and self.assessment:
                try:
                    self.assessment_row.clear()
                except Exception as ex:
                    ui.notify(str(ex))
                self.assessment = None
                self.learner = None
            self.dcm = dcm
            self.ringspecs_view.update_rings(dcm.competence_tree)
            self.assess_state()
            dcm_chart = DcmChart(dcm)
            svg_markup = dcm_chart.generate_svg_markup(
                learner=learner,
                selected_paths=selected_paths,
                config=self.svg.config,
                with_java_script=False,
                text_mode=self.text_mode,
            )
            # Use the new get_java_script method to get the JavaScript
            self.svg_view.content = (svg_markup,)
            self.svg_view.update()
        except Exception as ex:
            self.handle_exception(ex)

    def prepare_ui(self):
        """
        prepare the user interface
        """
        self.user_id = app.storage.browser["id"]
        self.prepare_svg()

    def prepare_svg(self):
        """
        prepare the SVG / javascript display
        """
        config = SVGConfig(with_popup=True)
        self.svg = SVG(config=config)
        java_script = self.svg.get_java_script()

        # Add the script using ui.add_head_html()
        ui.add_head_html(java_script, shared=True)

    def show_ui(self):
        """
        show the ui
        """
        with self.content_div:
            with ui.splitter() as splitter:
                with splitter.before:
                    with ui.grid(columns=2).classes("w-full") as self.left_selection:
                        extensions = {"json": ".json", "yaml": ".yaml"}
                        self.example_selector = FileSelector(
                            path=self.webserver.root_path,
                            extensions=extensions,
                            handler=self.read_and_optionally_render,
                        )
                        self.ringspecs_view = RingSpecsView(self)
                    with ui.grid(columns=1).classes("w-full") as self.left_grid:
                        with ui.row() as self.input_row:
                            self.input_input = ui.input(
                                value=self.input, on_change=self.input_changed
                            ).props("size=100")
                        with ui.row() as self.button_row:
                            self.tool_button(
                                tooltip="reload",
                                icon="refresh",
                                handler=self.reload_file,
                            )
                            self.assessment_button = self.tool_button(
                                tooltip="assessment",
                                icon="query_stats",
                                handler=self.new_assess,
                            )
                            if self.is_local:
                                self.tool_button(
                                    tooltip="open",
                                    icon="file_open",
                                    handler=self.open_file,
                                )
                            self.download_button = self.tool_button(
                                tooltip="download",
                                icon="download",
                                handler=self.download,
                            )
                with splitter.after:
                    self.svg_view = ui.html("")

    async def home(
        self,
    ):
        """Generates the home page with a selection of examples and
        svg display
        """
        await self.setup_content_div(self.show_ui)
        self.assess_state()

    def assess_learner(self, dcm, learner):
        """
        assess the given Dynamic Competence Map and learner

        Args:
            dcm(DynamicCompetenceMap): the competence map
            learner(Learner): the learner to get the self assessment for

        """
        if not self.content_div:
            return
        with self.content_div:
            if self.assessment is not None:
                self.assessment.reset(dcm=dcm, learner=learner)
            else:
                with self.left_grid:
                    with ui.row() as self.assessment_row:
                        self.assessment = Assessment(self, dcm=dcm, learner=learner)
            self.assessment.step(0)

    def new_assess(self):
        """
        run a new  assessment for a new learner
        """
        try:
            learner_id = f"{uuid.uuid4()}"
            self.learner = Learner(learner_id)
            self.save_session_state()
            self.assess_learner(self.dcm, self.learner)
        except Exception as ex:
            self.handle_exception(ex)

    async def assess_learner_by_slug(self, learner_slug: str):
        """
        Assess a learner based on the slug of the id

        Args:
            learner_slug (str): The unique slug of the learner.

        Raises:
            HTTPException: If the learner file does not exist or an error occurs.
        """

        def show():
            try:
                self.show_ui()
                self.load_learner(learner_slug)
                self.assess(self.learner)
            except Exception as ex:
                self.handle_exception(ex)

        await self.setup_content_div(show)

    def assess(self, learner: Learner, tree_id: str = None):
        """
        run an assessment for the given learner

        Args:
            learner(Learner): the learner to get the self assessment for
            tree_id(str): the identifier for the competence tree
        """
        if tree_id is None:
            tree_ids = learner.get_competence_tree_ids()
            if len(tree_ids) != 1:
                raise Exception(
                    f"There must be exactly one competence tree referenced but there are: {tree_ids}"
                )
            tree_id = tree_ids[0]
        if not tree_id in self.webserver.examples:
            raise Exception(f"invalid competence tree_id {tree_id}")
        dcm = self.webserver.examples[tree_id]
        # assess_learner will render ...
        # self.render_dcm(dcm,learner=learner)
        self.assess_learner(dcm, learner)

    def on_update_ringspecs(self):
        """
        react on changes in the ringspecs
        """
        if self.learner:
            self.render_item(self.learner)
        else:
            self.render_item(self.dcm)

    def assess_state(self):
        """
        save the session state and reflect
        in the UI
        """
        self.save_session_state()
        if self.dcm and self.learner is None:
            # allow creating a new learner
            self.assessment_button.enable()
        else:
            # the assessment is already on
            self.assessment_button.disable()
        if self.assessment is not None:
            # downloading is possible
            self.download_button.enable()
        else:
            # downloading is not possible - we have n learner
            self.download_button.disable()

    async def download(self, _args):
        """
        allow downloading the assessment result
        """
        try:
            with self.content_div:
                if not self.assessment:
                    ui.notify("no active learner assessment")
                    return
                json_path = self.assessment.store()
                ui.notify(f"downloading {json_path}")
                ui.download(json_path)
        except Exception as ex:
            self.handle_exception(ex)

__init__(webserver, client)

Initialize the solution

Calls the constructor of the base solution Args: webserver (DynamicCompotenceMapWebServer): The webserver instance associated with this context. client (Client): The client instance this context is associated with.

Source code in dcm/dcm_webserver.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
def __init__(self, webserver: DynamicCompentenceMapWebServer, client: Client):
    """
    Initialize the solution

    Calls the constructor of the base solution
    Args:
        webserver (DynamicCompotenceMapWebServer): The webserver instance associated with this context.
        client (Client): The client instance this context is associated with.
    """
    super().__init__(webserver, client)  # Call to the superclass constructor
    self.dcm = None
    self.learner = None
    self.assessment = None
    self.text_mode = "empty"

assess(learner, tree_id=None)

run an assessment for the given learner

Parameters:

Name Type Description Default
learner(Learner)

the learner to get the self assessment for

required
tree_id(str)

the identifier for the competence tree

required
Source code in dcm/dcm_webserver.py
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
def assess(self, learner: Learner, tree_id: str = None):
    """
    run an assessment for the given learner

    Args:
        learner(Learner): the learner to get the self assessment for
        tree_id(str): the identifier for the competence tree
    """
    if tree_id is None:
        tree_ids = learner.get_competence_tree_ids()
        if len(tree_ids) != 1:
            raise Exception(
                f"There must be exactly one competence tree referenced but there are: {tree_ids}"
            )
        tree_id = tree_ids[0]
    if not tree_id in self.webserver.examples:
        raise Exception(f"invalid competence tree_id {tree_id}")
    dcm = self.webserver.examples[tree_id]
    # assess_learner will render ...
    # self.render_dcm(dcm,learner=learner)
    self.assess_learner(dcm, learner)

assess_learner(dcm, learner)

assess the given Dynamic Competence Map and learner

Parameters:

Name Type Description Default
dcm(DynamicCompetenceMap)

the competence map

required
learner(Learner)

the learner to get the self assessment for

required
Source code in dcm/dcm_webserver.py
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
def assess_learner(self, dcm, learner):
    """
    assess the given Dynamic Competence Map and learner

    Args:
        dcm(DynamicCompetenceMap): the competence map
        learner(Learner): the learner to get the self assessment for

    """
    if not self.content_div:
        return
    with self.content_div:
        if self.assessment is not None:
            self.assessment.reset(dcm=dcm, learner=learner)
        else:
            with self.left_grid:
                with ui.row() as self.assessment_row:
                    self.assessment = Assessment(self, dcm=dcm, learner=learner)
        self.assessment.step(0)

assess_learner_by_slug(learner_slug) async

Assess a learner based on the slug of the id

Parameters:

Name Type Description Default
learner_slug str

The unique slug of the learner.

required

Raises:

Type Description
HTTPException

If the learner file does not exist or an error occurs.

Source code in dcm/dcm_webserver.py
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
async def assess_learner_by_slug(self, learner_slug: str):
    """
    Assess a learner based on the slug of the id

    Args:
        learner_slug (str): The unique slug of the learner.

    Raises:
        HTTPException: If the learner file does not exist or an error occurs.
    """

    def show():
        try:
            self.show_ui()
            self.load_learner(learner_slug)
            self.assess(self.learner)
        except Exception as ex:
            self.handle_exception(ex)

    await self.setup_content_div(show)

assess_state()

save the session state and reflect in the UI

Source code in dcm/dcm_webserver.py
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
def assess_state(self):
    """
    save the session state and reflect
    in the UI
    """
    self.save_session_state()
    if self.dcm and self.learner is None:
        # allow creating a new learner
        self.assessment_button.enable()
    else:
        # the assessment is already on
        self.assessment_button.disable()
    if self.assessment is not None:
        # downloading is possible
        self.download_button.enable()
    else:
        # downloading is not possible - we have n learner
        self.download_button.disable()

download(_args) async

allow downloading the assessment result

Source code in dcm/dcm_webserver.py
538
539
540
541
542
543
544
545
546
547
548
549
550
551
async def download(self, _args):
    """
    allow downloading the assessment result
    """
    try:
        with self.content_div:
            if not self.assessment:
                ui.notify("no active learner assessment")
                return
            json_path = self.assessment.store()
            ui.notify(f"downloading {json_path}")
            ui.download(json_path)
    except Exception as ex:
        self.handle_exception(ex)

get_learner_file_path(learner_slug)

Get the file path for a learner's JSON file based on the learner's slug.

Parameters:

Name Type Description Default
learner_slug str

The unique slug of the learner.

required

Returns:

Name Type Description
str str

The file path for the learner's JSON file.

Source code in dcm/dcm_webserver.py
258
259
260
261
262
263
264
265
266
267
268
def get_learner_file_path(self, learner_slug: str) -> str:
    """
    Get the file path for a learner's JSON file based on the learner's slug.

    Args:
        learner_slug (str): The unique slug of the learner.

    Returns:
        str: The file path for the learner's JSON file.
    """
    return os.path.join(self.config.storage_path, f"{learner_slug}.json")

home() async

Generates the home page with a selection of examples and svg display

Source code in dcm/dcm_webserver.py
426
427
428
429
430
431
432
433
async def home(
    self,
):
    """Generates the home page with a selection of examples and
    svg display
    """
    await self.setup_content_div(self.show_ui)
    self.assess_state()

load_learner(learner_slug)

Load a learner from a JSON file based on the learner's slug.

Parameters:

Name Type Description Default
learner_slug str

The unique slug of the learner.

required

Raises:

Type Description
FileNotFoundError

If the learner file does not exist.

Source code in dcm/dcm_webserver.py
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
def load_learner(self, learner_slug: str) -> None:
    """
    Load a learner from a JSON file based on the learner's slug.

    Args:
        learner_slug (str): The unique slug of the learner.

    Raises:
        FileNotFoundError: If the learner file does not exist.
    """
    learner_file = self.get_learner_file_path(learner_slug)
    if not os.path.exists(learner_file):
        raise FileNotFoundError(f"Learner file not found: {learner_file}")
    self.learner = Learner.load_from_json_file(learner_file)
    self.save_session_state()

new_assess()

run a new assessment for a new learner

Source code in dcm/dcm_webserver.py
455
456
457
458
459
460
461
462
463
464
465
def new_assess(self):
    """
    run a new  assessment for a new learner
    """
    try:
        learner_id = f"{uuid.uuid4()}"
        self.learner = Learner(learner_id)
        self.save_session_state()
        self.assess_learner(self.dcm, self.learner)
    except Exception as ex:
        self.handle_exception(ex)

on_update_ringspecs()

react on changes in the ringspecs

Source code in dcm/dcm_webserver.py
510
511
512
513
514
515
516
517
def on_update_ringspecs(self):
    """
    react on changes in the ringspecs
    """
    if self.learner:
        self.render_item(self.learner)
    else:
        self.render_item(self.dcm)

prepare_svg()

prepare the SVG / javascript display

Source code in dcm/dcm_webserver.py
370
371
372
373
374
375
376
377
378
379
def prepare_svg(self):
    """
    prepare the SVG / javascript display
    """
    config = SVGConfig(with_popup=True)
    self.svg = SVG(config=config)
    java_script = self.svg.get_java_script()

    # Add the script using ui.add_head_html()
    ui.add_head_html(java_script, shared=True)

prepare_ui()

prepare the user interface

Source code in dcm/dcm_webserver.py
363
364
365
366
367
368
def prepare_ui(self):
    """
    prepare the user interface
    """
    self.user_id = app.storage.browser["id"]
    self.prepare_svg()

render(_click_args=None) async

Renders the json content as an SVG visualization

Parameters:

Name Type Description Default
click_args object

The click event arguments.

required
Source code in dcm/dcm_webserver.py
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
async def render(self, _click_args=None):
    """
    Renders the json content as an SVG visualization

    Args:
        click_args (object): The click event arguments.
    """
    try:
        input_source = self.input
        if input_source:
            name = self.get_basename_without_extension(input_source)
            with self.content_div:
                ui.notify(f"rendering {name}")
            definition = self.do_read_input(input_source)
            # Determine the format based on the file extension
            markup = "json" if input_source.endswith(".json") else "yaml"
            if "learner_id" in definition:
                content_class = Learner
            else:
                content_class = CompetenceTree
            item = DynamicCompetenceMap.from_definition_string(
                name, definition, content_class=content_class, markup=markup
            )
            self.render_item(item)
    except Exception as ex:
        self.handle_exception(ex)

render_dcm(dcm, learner=None, selected_paths=[], clear_assessment=True)

render the dynamic competence map

Parameters:

Name Type Description Default
selected_paths List

A list of paths that should be highlighted

[]
Source code in dcm/dcm_webserver.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
def render_dcm(
    self,
    dcm,
    learner: Learner = None,
    selected_paths: List = [],
    clear_assessment: bool = True,
):
    """
    render the dynamic competence map

    Args:
        dcm(DynamicCompetenceMap)
        selected_paths (List, optional): A list of paths that should be highlighted
        in the SVG. These paths typically represent specific competencies or
        achievements. Defaults to an empty list.

    """
    try:
        if clear_assessment and self.assessment:
            try:
                self.assessment_row.clear()
            except Exception as ex:
                ui.notify(str(ex))
            self.assessment = None
            self.learner = None
        self.dcm = dcm
        self.ringspecs_view.update_rings(dcm.competence_tree)
        self.assess_state()
        dcm_chart = DcmChart(dcm)
        svg_markup = dcm_chart.generate_svg_markup(
            learner=learner,
            selected_paths=selected_paths,
            config=self.svg.config,
            with_java_script=False,
            text_mode=self.text_mode,
        )
        # Use the new get_java_script method to get the JavaScript
        self.svg_view.content = (svg_markup,)
        self.svg_view.update()
    except Exception as ex:
        self.handle_exception(ex)

save_session_state()

Save the current session state to app.storage.user.

Source code in dcm/dcm_webserver.py
250
251
252
253
254
255
256
def save_session_state(self) -> None:
    """
    Save the current session state to app.storage.user.
    """
    learner_id = self.learner.learner_id if self.learner else None
    app.storage.user["learner_id"] = learner_id
    app.storage.user["assessment"] = self.assessment is not None

show_ui()

show the ui

Source code in dcm/dcm_webserver.py
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
def show_ui(self):
    """
    show the ui
    """
    with self.content_div:
        with ui.splitter() as splitter:
            with splitter.before:
                with ui.grid(columns=2).classes("w-full") as self.left_selection:
                    extensions = {"json": ".json", "yaml": ".yaml"}
                    self.example_selector = FileSelector(
                        path=self.webserver.root_path,
                        extensions=extensions,
                        handler=self.read_and_optionally_render,
                    )
                    self.ringspecs_view = RingSpecsView(self)
                with ui.grid(columns=1).classes("w-full") as self.left_grid:
                    with ui.row() as self.input_row:
                        self.input_input = ui.input(
                            value=self.input, on_change=self.input_changed
                        ).props("size=100")
                    with ui.row() as self.button_row:
                        self.tool_button(
                            tooltip="reload",
                            icon="refresh",
                            handler=self.reload_file,
                        )
                        self.assessment_button = self.tool_button(
                            tooltip="assessment",
                            icon="query_stats",
                            handler=self.new_assess,
                        )
                        if self.is_local:
                            self.tool_button(
                                tooltip="open",
                                icon="file_open",
                                handler=self.open_file,
                            )
                        self.download_button = self.tool_button(
                            tooltip="download",
                            icon="download",
                            handler=self.download,
                        )
            with splitter.after:
                self.svg_view = ui.html("")

DynamicCompentenceMapWebServer

Bases: InputWebserver

server to supply Dynamic Competence Map Visualizations

Source code in dcm/dcm_webserver.py
 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
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
class DynamicCompentenceMapWebServer(InputWebserver):
    """
    server to supply Dynamic Competence Map Visualizations
    """

    @classmethod
    def get_config(cls) -> WebserverConfig:
        """
        get the configuration for this Webserver
        """
        copy_right = ""
        config = WebserverConfig(
            short_name="dcm",
            copy_right=copy_right,
            version=Version(),
            default_port=8885,
        )
        server_config = WebserverConfig.get(config)
        server_config.solution_class = DcmSolution
        return server_config

    def __init__(self):
        """Constructs all the necessary attributes for the WebServer object."""
        InputWebserver.__init__(
            self, config=DynamicCompentenceMapWebServer.get_config()
        )
        self.examples = DynamicCompetenceMap.get_examples(markup="yaml")

        # FastAPI endpoints
        @app.post("/svg/")
        async def render_svg(svg_render_request: SVGRenderRequest) -> HTMLResponse:
            """
            render the given request
            """
            return await self.render_svg(svg_render_request)

        @app.get("/description/{tree_id}/{aspect_id}/{area_id}/{facet_id}")
        async def get_description_for_facet(
            tree_id: str,
            aspect_id: str = None,
            area_id: str = None,
            facet_id: str = None,
        ) -> HTMLResponse:
            """
            Endpoints to get the description of a competence facet


            Args:
                tree_id (str): ID of the tree
                area_id (str): ID of the area
                aspect_id (str, optional): ID of the aspect. Defaults to None.
                facet_id (str, optional): ID of the facet. Defaults to None.

            Returns:
                HTMLResponse: HTML content of the description.
            """
            path = f"{tree_id}/{aspect_id}/{area_id}/{facet_id}"
            return await self.show_description(path)

        @app.get("/description/{tree_id}/{aspect_id}/{area_id}")
        async def get_description_for_area(
            tree_id: str, aspect_id: str = None, area_id: str = None
        ) -> HTMLResponse:
            """
            Endpoints to get the description of a
            competence area

            Args:
                tree_id (str): ID of the tree
                area_id (str): ID of the area
                aspect_id (str, optional): ID of the aspect. Defaults to None.

            Returns:
                HTMLResponse: HTML content of the description.
            """
            path = f"{tree_id}/{aspect_id}/{area_id}"
            return await self.show_description(path)

        @app.get("/description/{tree_id}/{aspect_id}")
        async def get_description_for_aspect(
            tree_id: str, aspect_id: str = None
        ) -> HTMLResponse:
            """
            Endpoint to get the description of a competence aspect

            Args:
                tree_id (str): ID of the tree
                area_id (str): ID of the area

            Returns:
                HTMLResponse: HTML content of the description.
            """
            path = f"{tree_id}/{aspect_id}"
            return await self.show_description(path)

        @app.get("/description/{tree_id}")
        async def get_description_for_tree(tree_id: str) -> HTMLResponse:
            """
            Endpoint to get the description of a competence tree

            Args:
                tree_id (str): ID of the tree

            Returns:
                HTMLResponse: HTML content of the description.
            """
            path = f"{tree_id}"
            return await self.show_description(path)

        # nicegui RESTFul endpoints
        @ui.page("/learner/{learner_slug}")
        async def show_learner(client: Client, learner_slug: str):
            return await self.page(
                client, DcmSolution.assess_learner_by_slug, learner_slug
            )

    async def render_svg(self, svg_render_request: SVGRenderRequest) -> HTMLResponse:
        """
        render the given request
        """
        r = svg_render_request
        dcm = DynamicCompetenceMap.from_definition_string(
            r.name, r.definition, content_class=CompetenceTree, markup=r.markup
        )
        dcm_chart = DcmChart(dcm)
        svg_markup = dcm_chart.generate_svg_markup(
            config=r.config, with_java_script=True, text_mode=r.text_mode
        )
        response = HTMLResponse(content=svg_markup)
        return response

    async def show_description(self, path: str = None) -> HTMLResponse:
        """
        Show the HTML description of a specific
        competence element given by the path

        Args:
            path(str): the path identifying the element

        Returns:
            HTMLResponse: The response object containing the HTML-formatted description.

        Raises:
            HTTPException: If the example name provided does not exist in the examples collection.
        """
        path_parts = path.split("/")
        tree_id = path_parts[0]
        if tree_id in self.examples:
            example = self.examples[tree_id]
            element = example.competence_tree.lookup_by_path(path)
            if element:
                content = element.as_html()
                return HTMLResponse(content=content)
            else:
                content = f"No element found for {path} in {tree_id}"
                return HTMLResponse(content=content, status_code=404)
        else:
            msg = f"unknown competence tree {tree_id}"
            raise HTTPException(status_code=404, detail=msg)

    def configure_run(self):
        """
        configure the allowed urls
        """
        InputWebserver.configure_run(self)
        self.allowed_urls = [
            # "https://raw.githubusercontent.com/JuanIrache/DJI_SRT_Parser/master/samples/",
            # "https://raw.githubusercontent.com/JuanIrache/dji-srt-viewer/master/samples/",
            # "https://cycle.travel/gpx/",
            # "https://cycle.travel/map/journey/",
            DynamicCompetenceMap.examples_path(),
            self.root_path,
        ]
        pass

__init__()

Constructs all the necessary attributes for the WebServer object.

Source code in dcm/dcm_webserver.py
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
def __init__(self):
    """Constructs all the necessary attributes for the WebServer object."""
    InputWebserver.__init__(
        self, config=DynamicCompentenceMapWebServer.get_config()
    )
    self.examples = DynamicCompetenceMap.get_examples(markup="yaml")

    # FastAPI endpoints
    @app.post("/svg/")
    async def render_svg(svg_render_request: SVGRenderRequest) -> HTMLResponse:
        """
        render the given request
        """
        return await self.render_svg(svg_render_request)

    @app.get("/description/{tree_id}/{aspect_id}/{area_id}/{facet_id}")
    async def get_description_for_facet(
        tree_id: str,
        aspect_id: str = None,
        area_id: str = None,
        facet_id: str = None,
    ) -> HTMLResponse:
        """
        Endpoints to get the description of a competence facet


        Args:
            tree_id (str): ID of the tree
            area_id (str): ID of the area
            aspect_id (str, optional): ID of the aspect. Defaults to None.
            facet_id (str, optional): ID of the facet. Defaults to None.

        Returns:
            HTMLResponse: HTML content of the description.
        """
        path = f"{tree_id}/{aspect_id}/{area_id}/{facet_id}"
        return await self.show_description(path)

    @app.get("/description/{tree_id}/{aspect_id}/{area_id}")
    async def get_description_for_area(
        tree_id: str, aspect_id: str = None, area_id: str = None
    ) -> HTMLResponse:
        """
        Endpoints to get the description of a
        competence area

        Args:
            tree_id (str): ID of the tree
            area_id (str): ID of the area
            aspect_id (str, optional): ID of the aspect. Defaults to None.

        Returns:
            HTMLResponse: HTML content of the description.
        """
        path = f"{tree_id}/{aspect_id}/{area_id}"
        return await self.show_description(path)

    @app.get("/description/{tree_id}/{aspect_id}")
    async def get_description_for_aspect(
        tree_id: str, aspect_id: str = None
    ) -> HTMLResponse:
        """
        Endpoint to get the description of a competence aspect

        Args:
            tree_id (str): ID of the tree
            area_id (str): ID of the area

        Returns:
            HTMLResponse: HTML content of the description.
        """
        path = f"{tree_id}/{aspect_id}"
        return await self.show_description(path)

    @app.get("/description/{tree_id}")
    async def get_description_for_tree(tree_id: str) -> HTMLResponse:
        """
        Endpoint to get the description of a competence tree

        Args:
            tree_id (str): ID of the tree

        Returns:
            HTMLResponse: HTML content of the description.
        """
        path = f"{tree_id}"
        return await self.show_description(path)

    # nicegui RESTFul endpoints
    @ui.page("/learner/{learner_slug}")
    async def show_learner(client: Client, learner_slug: str):
        return await self.page(
            client, DcmSolution.assess_learner_by_slug, learner_slug
        )

configure_run()

configure the allowed urls

Source code in dcm/dcm_webserver.py
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def configure_run(self):
    """
    configure the allowed urls
    """
    InputWebserver.configure_run(self)
    self.allowed_urls = [
        # "https://raw.githubusercontent.com/JuanIrache/DJI_SRT_Parser/master/samples/",
        # "https://raw.githubusercontent.com/JuanIrache/dji-srt-viewer/master/samples/",
        # "https://cycle.travel/gpx/",
        # "https://cycle.travel/map/journey/",
        DynamicCompetenceMap.examples_path(),
        self.root_path,
    ]
    pass

get_config() classmethod

get the configuration for this Webserver

Source code in dcm/dcm_webserver.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@classmethod
def get_config(cls) -> WebserverConfig:
    """
    get the configuration for this Webserver
    """
    copy_right = ""
    config = WebserverConfig(
        short_name="dcm",
        copy_right=copy_right,
        version=Version(),
        default_port=8885,
    )
    server_config = WebserverConfig.get(config)
    server_config.solution_class = DcmSolution
    return server_config

render_svg(svg_render_request) async

render the given request

Source code in dcm/dcm_webserver.py
162
163
164
165
166
167
168
169
170
171
172
173
174
175
async def render_svg(self, svg_render_request: SVGRenderRequest) -> HTMLResponse:
    """
    render the given request
    """
    r = svg_render_request
    dcm = DynamicCompetenceMap.from_definition_string(
        r.name, r.definition, content_class=CompetenceTree, markup=r.markup
    )
    dcm_chart = DcmChart(dcm)
    svg_markup = dcm_chart.generate_svg_markup(
        config=r.config, with_java_script=True, text_mode=r.text_mode
    )
    response = HTMLResponse(content=svg_markup)
    return response

show_description(path=None) async

Show the HTML description of a specific competence element given by the path

Parameters:

Name Type Description Default
path(str)

the path identifying the element

required

Returns:

Name Type Description
HTMLResponse HTMLResponse

The response object containing the HTML-formatted description.

Raises:

Type Description
HTTPException

If the example name provided does not exist in the examples collection.

Source code in dcm/dcm_webserver.py
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
async def show_description(self, path: str = None) -> HTMLResponse:
    """
    Show the HTML description of a specific
    competence element given by the path

    Args:
        path(str): the path identifying the element

    Returns:
        HTMLResponse: The response object containing the HTML-formatted description.

    Raises:
        HTTPException: If the example name provided does not exist in the examples collection.
    """
    path_parts = path.split("/")
    tree_id = path_parts[0]
    if tree_id in self.examples:
        example = self.examples[tree_id]
        element = example.competence_tree.lookup_by_path(path)
        if element:
            content = element.as_html()
            return HTMLResponse(content=content)
        else:
            content = f"No element found for {path} in {tree_id}"
            return HTMLResponse(content=content, status_code=404)
    else:
        msg = f"unknown competence tree {tree_id}"
        raise HTTPException(status_code=404, detail=msg)

SVGRenderRequest

Bases: BaseModel

A request for rendering an SVG.

Attributes:

Name Type Description
name str

The name of the render request.

definition str

The string representation of the data to be rendered, in either JSON or YAML format.

markup str

The format of the definition ('json' or 'yaml').

config SVGConfig

Optional configuration for SVG rendering. Defaults to None, which uses default settings.

Source code in dcm/dcm_webserver.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class SVGRenderRequest(BaseModel):
    """
    A request for rendering an SVG.

    Attributes:
        name (str): The name of the render request.
        definition (str): The string representation of the data to be rendered, in either JSON or YAML format.
        markup (str): The format of the definition ('json' or 'yaml').
        config (SVGConfig): Optional configuration for SVG rendering. Defaults to None, which uses default settings.
    """

    name: str
    definition: str
    markup: str
    text_mode: Optional[str] = "empty"
    config: Optional[SVGConfig] = None

linkml

dcm_model

Achievement dataclass

Bases: YAMLRoot

A record of an achievement attained by a learner.

Source code in dcm/linkml/dcm_model.py
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
@dataclass
class Achievement(YAMLRoot):
    """
    A record of an achievement attained by a learner.
    """
    _inherited_slots: ClassVar[List[str]] = []

    class_class_uri: ClassVar[URIRef] = DCM.Achievement
    class_class_curie: ClassVar[str] = "dcm:Achievement"
    class_name: ClassVar[str] = "Achievement"
    class_model_uri: ClassVar[URIRef] = DCM.Achievement

    path: Optional[str] = None
    level: Optional[int] = None
    score: Optional[float] = None
    date_assessed_iso: Optional[str] = None

    def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]):
        if self.path is not None and not isinstance(self.path, str):
            self.path = str(self.path)

        if self.level is not None and not isinstance(self.level, int):
            self.level = int(self.level)

        if self.score is not None and not isinstance(self.score, float):
            self.score = float(self.score)

        if self.date_assessed_iso is not None and not isinstance(self.date_assessed_iso, str):
            self.date_assessed_iso = str(self.date_assessed_iso)

        super().__post_init__(**kwargs)

Learner dataclass

Bases: YAMLRoot

An individual learner with a unique identifier and a list of achievements.

Source code in dcm/linkml/dcm_model.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
@dataclass
class Learner(YAMLRoot):
    """
    An individual learner with a unique identifier and a list of achievements.
    """
    _inherited_slots: ClassVar[List[str]] = []

    class_class_uri: ClassVar[URIRef] = DCM.Learner
    class_class_curie: ClassVar[str] = "dcm:Learner"
    class_name: ClassVar[str] = "Learner"
    class_model_uri: ClassVar[URIRef] = DCM.Learner

    learner_id: Optional[str] = None
    achievements: Optional[Union[Union[dict, "Achievement"], List[Union[dict, "Achievement"]]]] = empty_list()

    def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]):
        if self.learner_id is not None and not isinstance(self.learner_id, str):
            self.learner_id = str(self.learner_id)

        if not isinstance(self.achievements, list):
            self.achievements = [self.achievements] if self.achievements is not None else []
        self.achievements = [v if isinstance(v, Achievement) else Achievement(**as_dict(v)) for v in self.achievements]

        super().__post_init__(**kwargs)

radar_chart

Created on 2024-02-05

@author: wf

RadarChart

a radar chart

Source code in dcm/radar_chart.py
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 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
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
class RadarChart:
    """
    a radar chart
    """

    def __init__(self, svg: SVG, max_score: float = 100.0):
        self.svg = svg
        self.radius = self.svg.config.width / 2
        self.center_x = self.radius
        self.center_y = self.radius
        self.max_score = max_score

    def add_scale_circles(
        self,
        num_circles: int = 10,
        stroke_width: float = 1.0,
        stroke_color: str = "black",
    ):
        """
        Add concentric circles to the SVG based on the SVG's configuration.

        Args:
            num_circles (int): The number of concentric circles to draw.
            stroke_width (float): The stroke width of the circle lines.
            stroke_color (str): The color of the circle lines.
        """
        for i in range(1, num_circles + 1):
            circle_radius = (self.radius * i) / num_circles
            self.svg.add_circle(
                SVGNodeConfig(
                    x=self.center_x,
                    y=self.center_y,
                    width=circle_radius,
                    stroke_width=stroke_width,
                    color=stroke_color,
                    fill="none",  # Ensure circles are not filled
                )
            )

    def calculate_radar_chart_points(
        self, scores: List[float]
    ) -> List[Tuple[float, float]]:
        """
        Calculate the points for the radar chart based on the given scores.

        Args:
            scores (List[float]): The scores to be represented on the radar chart.

        Returns:
            List[Tuple[float, float]]: The list of points for the radar chart.
        """
        num_axes = len(scores)
        angle_per_axis = 2 * math.pi / num_axes  # Angle between each axis in radians

        points = []
        for i, score in enumerate(scores):
            angle = angle_per_axis * i  # Angle for this axis
            # Calculate the distance from the center for this point
            distance = (score / self.max_score) * self.radius
            x = self.center_x + distance * math.cos(angle)
            y = self.center_y + distance * math.sin(angle)
            points.append((x, y))

        return points

    def add_scores(
        self, scores: List[float], config: Optional[SVGNodeConfig] = None
    ) -> None:
        """
        Add the scores to the radar chart as a polygon.

        Args:
            scores (List[float]): The scores to be represented on the radar chart.
            config (SVGNodeConfig, optional): The configuration for the polygon representing the scores.
        """  # Use the function to calculate points for the scores
        radar_points = self.calculate_radar_chart_points(scores)
        if config is None:
            config = SVGNodeConfig(
                color="blue", fill="none", stroke_width=2.0, opacity=0.5
            )

        # Create a Polygon for the radar chart
        radar_chart_polygon = Polygon(
            points=radar_points,
            fill=config.fill,
            stroke_width=config.stroke_width,
            color=config.color,
            opacity=config.opacity,
        )
        self.svg.add_polygon(radar_chart_polygon)

add_scale_circles(num_circles=10, stroke_width=1.0, stroke_color='black')

Add concentric circles to the SVG based on the SVG's configuration.

Parameters:

Name Type Description Default
num_circles int

The number of concentric circles to draw.

10
stroke_width float

The stroke width of the circle lines.

1.0
stroke_color str

The color of the circle lines.

'black'
Source code in dcm/radar_chart.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
def add_scale_circles(
    self,
    num_circles: int = 10,
    stroke_width: float = 1.0,
    stroke_color: str = "black",
):
    """
    Add concentric circles to the SVG based on the SVG's configuration.

    Args:
        num_circles (int): The number of concentric circles to draw.
        stroke_width (float): The stroke width of the circle lines.
        stroke_color (str): The color of the circle lines.
    """
    for i in range(1, num_circles + 1):
        circle_radius = (self.radius * i) / num_circles
        self.svg.add_circle(
            SVGNodeConfig(
                x=self.center_x,
                y=self.center_y,
                width=circle_radius,
                stroke_width=stroke_width,
                color=stroke_color,
                fill="none",  # Ensure circles are not filled
            )
        )

add_scores(scores, config=None)

Add the scores to the radar chart as a polygon.

Parameters:

Name Type Description Default
scores List[float]

The scores to be represented on the radar chart.

required
config SVGNodeConfig

The configuration for the polygon representing the scores.

None
Source code in dcm/radar_chart.py
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def add_scores(
    self, scores: List[float], config: Optional[SVGNodeConfig] = None
) -> None:
    """
    Add the scores to the radar chart as a polygon.

    Args:
        scores (List[float]): The scores to be represented on the radar chart.
        config (SVGNodeConfig, optional): The configuration for the polygon representing the scores.
    """  # Use the function to calculate points for the scores
    radar_points = self.calculate_radar_chart_points(scores)
    if config is None:
        config = SVGNodeConfig(
            color="blue", fill="none", stroke_width=2.0, opacity=0.5
        )

    # Create a Polygon for the radar chart
    radar_chart_polygon = Polygon(
        points=radar_points,
        fill=config.fill,
        stroke_width=config.stroke_width,
        color=config.color,
        opacity=config.opacity,
    )
    self.svg.add_polygon(radar_chart_polygon)

calculate_radar_chart_points(scores)

Calculate the points for the radar chart based on the given scores.

Parameters:

Name Type Description Default
scores List[float]

The scores to be represented on the radar chart.

required

Returns:

Type Description
List[Tuple[float, float]]

List[Tuple[float, float]]: The list of points for the radar chart.

Source code in dcm/radar_chart.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
def calculate_radar_chart_points(
    self, scores: List[float]
) -> List[Tuple[float, float]]:
    """
    Calculate the points for the radar chart based on the given scores.

    Args:
        scores (List[float]): The scores to be represented on the radar chart.

    Returns:
        List[Tuple[float, float]]: The list of points for the radar chart.
    """
    num_axes = len(scores)
    angle_per_axis = 2 * math.pi / num_axes  # Angle between each axis in radians

    points = []
    for i, score in enumerate(scores):
        angle = angle_per_axis * i  # Angle for this axis
        # Calculate the distance from the center for this point
        distance = (score / self.max_score) * self.radius
        x = self.center_x + distance * math.cos(angle)
        y = self.center_y + distance * math.sin(angle)
        points.append((x, y))

    return points

svg

DonutSegment dataclass

Bases: SVGNode

A donut segment representing a section of a donut chart.

Source code in dcm/svg.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
@dataclass
class DonutSegment(SVGNode):
    """
    A donut segment representing a
    section of a donut chart.
    """

    cx: float = 0.0
    cy: float = 0.0
    inner_radius: float = 0.0
    outer_radius: float = 0.0
    start_angle: Optional[float] = 0.0
    end_angle: Optional[float] = 360.0
    text_mode: Optional[str] = None

    @property
    def large_arc_flag(self) -> str:
        """
        Determine if the arc should be drawn as a large-arc (values >= 180 degrees).

        Returns:
            str: "1" if the arc is a large arc, otherwise "0".
        """
        large_arc_flag = "1" if self.end_angle - self.start_angle >= 180 else "0"
        return large_arc_flag

    def relative_angle(self, angle_factor=0.5) -> float:
        """Calculate a relative angle of the donut segment."""
        relative_angle = (self.start_angle + self.end_angle) * angle_factor
        return relative_angle

    def radial_radius(self, radial_offset: float) -> float:
        """
        get the radial radius for the given radial offset
        """
        radial_radius = (
            self.inner_radius + (self.outer_radius - self.inner_radius) * radial_offset
        )
        return radial_radius

    def x_y(self, angle: float, radial_radius: float):
        x = self.cx + radial_radius * math.cos(math.radians(angle))
        y = self.cy + radial_radius * math.sin(math.radians(angle))
        return x, y

    def get_arc(self, radial_offset: float = 0.5) -> Arc:
        """
        Get the Arc for the given radial offset

        Args:
            radial_offset(float): e.g. 0.0 - inner 1.0 outer 0.5 middle
        Returns:
            Arc: the arc at the given radial offset
        """
        # Calculate the adjusted radius within the bounds of inner and outer radii
        radial_radius = self.radial_radius(radial_offset)

        # Calculate the start and end points of the arc
        start_x, start_y = self.x_y(self.start_angle, radial_radius)
        end_x, end_y = self.x_y(self.end_angle, radial_radius)
        middle_angle = self.relative_angle(0.5)
        middle_x, middle_y = self.x_y(middle_angle, radial_radius)

        return Arc(
            radius=radial_radius,
            start_x=start_x,
            start_y=start_y,
            middle_x=middle_x,
            middle_y=middle_y,
            end_x=end_x,
            end_y=end_y,
        )

large_arc_flag: str property

Determine if the arc should be drawn as a large-arc (values >= 180 degrees).

Returns:

Name Type Description
str str

"1" if the arc is a large arc, otherwise "0".

get_arc(radial_offset=0.5)

Get the Arc for the given radial offset

Parameters:

Name Type Description Default
radial_offset(float)

e.g. 0.0 - inner 1.0 outer 0.5 middle

required

Returns: Arc: the arc at the given radial offset

Source code in dcm/svg.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
def get_arc(self, radial_offset: float = 0.5) -> Arc:
    """
    Get the Arc for the given radial offset

    Args:
        radial_offset(float): e.g. 0.0 - inner 1.0 outer 0.5 middle
    Returns:
        Arc: the arc at the given radial offset
    """
    # Calculate the adjusted radius within the bounds of inner and outer radii
    radial_radius = self.radial_radius(radial_offset)

    # Calculate the start and end points of the arc
    start_x, start_y = self.x_y(self.start_angle, radial_radius)
    end_x, end_y = self.x_y(self.end_angle, radial_radius)
    middle_angle = self.relative_angle(0.5)
    middle_x, middle_y = self.x_y(middle_angle, radial_radius)

    return Arc(
        radius=radial_radius,
        start_x=start_x,
        start_y=start_y,
        middle_x=middle_x,
        middle_y=middle_y,
        end_x=end_x,
        end_y=end_y,
    )

radial_radius(radial_offset)

get the radial radius for the given radial offset

Source code in dcm/svg.py
176
177
178
179
180
181
182
183
def radial_radius(self, radial_offset: float) -> float:
    """
    get the radial radius for the given radial offset
    """
    radial_radius = (
        self.inner_radius + (self.outer_radius - self.inner_radius) * radial_offset
    )
    return radial_radius

relative_angle(angle_factor=0.5)

Calculate a relative angle of the donut segment.

Source code in dcm/svg.py
171
172
173
174
def relative_angle(self, angle_factor=0.5) -> float:
    """Calculate a relative angle of the donut segment."""
    relative_angle = (self.start_angle + self.end_angle) * angle_factor
    return relative_angle

Polygon dataclass

Bases: SVGNode

A polygon representing a series of data points in a radar chart.

Source code in dcm/svg.py
135
136
137
138
139
140
141
142
@dataclass
class Polygon(SVGNode):
    """
    A polygon representing a series of data points in a radar chart.
    """

    # List of points (x, y) forming the polygon
    points: List[Tuple[float, float]] = field(default_factory=list)

SVG

Class for creating SVG drawings.

Attributes:

Name Type Description
config SVGConfig

Configuration for the SVG drawing.

Source code in dcm/svg.py
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
class SVG:
    """
    Class for creating SVG drawings.

    Attributes:
        config (SVGConfig): Configuration for the SVG drawing.
    """

    def __init__(self, config: SVGConfig = None):
        """
        Initialize SVG object with given configuration.

        Args:
            config (SVGConfig): Configuration for SVG generation.
        """
        self.config = config if config else SVGConfig()
        self.width = self.config.width
        self.height = self.config.height
        self.elements = []
        self.indent = self.config.indent

    def get_indent(self, level) -> str:
        """
        get the indentation for the given level
        """
        indentation = f"{self.indent * level}"
        return indentation

    def get_svg_style(self) -> str:
        """
        Define styles for SVG elements.

        Returns:
            str: String containing style definitions for SVG.
        """
        style = (
            f"{self.indent}<style>\n"
            f"{self.indent * 2}.hoverable {{ cursor: pointer; fill-opacity: 1; stroke: black; stroke-width: 0.5; }}\n"
            f"{self.indent * 2}.hoverable:hover {{ fill-opacity: 0.7; }}\n"
            f"{self.indent * 2}.selected {{ fill-opacity: 0.5; stroke: blue !important; stroke-width: 1.5;}}\n"
            f"{self.indent * 2}.noclick {{ pointer-events: none; }}\n"  # style for non-clickable text
        )

        if self.config.with_popup:
            style += (
                f"{self.indent * 2}.popup {{\n"
                f"{self.indent * 3}border: 2px solid black;\n"
                f"{self.indent * 3}border-radius: 15px;\n"
                f"{self.indent * 3}overflow: auto;\n"  # changed to 'auto' to allow scrolling only if needed
                f"{self.indent * 3}background: white;\n"
                f"{self.indent * 3}box-sizing: border-box;\n"  # ensures padding and border are included
                f"{self.indent * 3}padding: 10px;\n"  # optional padding inside the popup
                f"{self.indent * 3}height: 100%;\n"  # adjusts height relative to foreignObject
                f"{self.indent * 3}width: 100%;\n"  # adjusts width relative to foreignObject
                f"{self.indent * 2}}}\n"
                f"{self.indent * 2}.close-btn {{\n"  # style for the close button
                f"{self.indent * 3}cursor: pointer;\n"
                f"{self.indent * 3}position: absolute;\n"
                f"{self.indent * 3}top: 0;\n"
                f"{self.indent * 3}right: 0;\n"
                f"{self.indent * 3}padding: 5px;\n"
                f"{self.indent * 3}font-size: 20px;\n"
                f"{self.indent * 3}user-select: none;\n"  # prevents text selection on click
                f"{self.indent * 2}}}\n"
            )

        style += f"{self.indent}</style>\n"
        return style

    def get_text_width(self, text: str) -> int:
        """
        Estimate the width of a text string in the SVG based on the font size and font name.

        Args:
            text (str): The text content.

        Returns:
            int: The estimated width of the text in pixels.
        """
        average_char_width_factor = 0.6
        average_char_width = average_char_width_factor * self.config.font_size
        return int(average_char_width * len(text))

    def get_text_rotation(self, rotation_angle: float) -> float:
        """
        Adjusts the rotation angle for SVG text elements to ensure that the text
        is upright and readable in a circular chart. The text will be rotated
        by 180 degrees if it is in the lower half of the chart (between 90 and 270 degrees).

        Args:
            rotation_angle (float): The initial rotation angle of the text element.

        Returns:
            float: The adjusted rotation angle for the text element.
        """
        # In the bottom half of the chart (90 to 270 degrees), the text
        # would appear upside down, so we rotate it by 180 degrees.
        if 90 <= rotation_angle < 270:
            rotation_angle -= 180

        # Return the adjusted angle. No adjustment is needed for the
        # top half of the chart as the text is already upright.
        return rotation_angle

    def get_donut_path(
        self,
        segment: DonutSegment,
        radial_offset: float = 0.5,
        middle_arc: bool = False,
    ) -> str:
        """
        Create an SVG path definition for an arc using the properties of a DonutSegment.

        Args:
            segment (DonutSegment): The segment for which to create the path.
            radial_offset(float): 0 to 1 - useable in middle_arc mode
            middle_arc(bool): if True get the middle arc

        Returns:
            str: SVG path definition string for the full donut segment or the middle_arc if middle_arc is set to true.
        """
        if middle_arc:
            arc = segment.get_arc(radial_offset=radial_offset)

            # Create the path for the middle arc
            path_str = (
                f"M {arc.start_x} {arc.start_y} "  # Move to start of middle arc
                f"A {arc.radius} {arc.radius} 0 {segment.large_arc_flag} 1 {arc.end_x} {arc.end_y}"
            )
        else:
            outer_arc = segment.get_arc(radial_offset=1)
            inner_arc = segment.get_arc(radial_offset=0)
            path_str = (
                f"M {inner_arc.start_x} {inner_arc.start_y} "  # Move to start of inner arc
                f"L {outer_arc.start_x} {outer_arc.start_y} "  # Line to start of outer arc
                f"A {segment.outer_radius} {segment.outer_radius} 0 {segment.large_arc_flag} 1 {outer_arc.end_x} {outer_arc.end_y} "  # Outer arc
                f"L {inner_arc.end_x} {inner_arc.end_y} "  # Line to end of inner arc
                f"A {segment.inner_radius} {segment.inner_radius} 0 {segment.large_arc_flag} 0 {inner_arc.start_x} {inner_arc.start_y} "  # Inner arc (reverse)
                "Z"
            )

        return path_str

    def add_element(self, element: str, indent_level: int = 1, comment: str = None):
        """
        Add an SVG element to the elements list with proper indentation.

        Args:
            element (str): SVG element to be added.
            indent_level (int): Indentation level for the element.
            comment(str): optional comment to add
        """
        base_indent = self.get_indent(indent_level)
        if comment:
            indented_comment = f"{base_indent}<!-- {comment} -->\n"
            self.elements.append(indented_comment)
        indented_element = f"{base_indent}{element}\n"
        self.elements.append(indented_element)

    def add_circle(self, config: SVGNodeConfig):
        """
        Add a circle element to the SVG, optionally making it clickable and with a hover effect.

        Args:
            config (SVGNodeConfig): Configuration for the circle element.
        """
        color = config.fill if config.fill else self.config.default_color
        circle_element = f'<circle cx="{config.x}" cy="{config.y}" r="{config.width}" fill="{color}" class="{config.element_class}" />'

        # If URL is provided, wrap the circle in an anchor tag to make it clickable
        if config.url:
            circle_indent = self.get_indent(config.indent_level + 1)
            circle_element = f"""<a xlink:href="{config.url}" target="_blank">
{circle_indent}{circle_element}
</a>"""

        # Use add_group to add the circle element with proper indentation
        self.add_group(
            circle_element,
            group_id=config.id,
            group_class=config.element_class,
            indent_level=config.indent_level,
            comment=config.comment,
        )

    def add_rectangle(
        self,
        x: int,
        y: int,
        width: int,
        height: int,
        fill: str = None,
        indent_level: int = 1,
    ):
        """
        Add a rectangle element to the SVG.

        Args:
            x (int): X-coordinate of the rectangle's top-left corner.
            y (int): Y-coordinate of the rectangle's top-left corner.
            width (int): Width of the rectangle.
            height (int): Height of the rectangle.
            fill (str, optional): Fill color of the rectangle. Defaults to the default color.
            indent_level (int): Indentation level for the rectangle.
        """
        color = fill if fill else self.config.default_color
        rect = f'<rect x="{x}" y="{y}" width="{width}" height="{height}" fill="{color}" />\n'
        self.add_element(rect, indent_level=indent_level)

    def add_polygon(self, polygon: Polygon, indent_level: int = 1):
        """
        Add a polygon to the SVG.

        Args:
            polygon (Polygon): The polygon to add to the SVG.
            indent_level (int): Indentation level for the element.
        """
        points_str = " ".join(f"{x},{y}" for x, y in polygon.points)
        fill_color = polygon.fill if polygon.fill else self.config.default_color
        opacity = polygon.opacity
        stroke_color = polygon.color if polygon.color else "black"
        stroke_width = polygon.stroke_width

        polygon_element = (
            f'<polygon points="{points_str}" '
            f'stroke="{stroke_color}" stroke-width="{stroke_width}" '
            f'fill="{fill_color}" fill-opacity="{opacity}" />\n'
        )

        # If a title (tooltip) is provided, create a title element
        title_element = (
            f"<title>{html.escape(polygon.title)}</title>\n" if polygon.title else ""
        )

        # Combine polygon and title into one string without adding indentation here
        group_content = f"{polygon_element}{title_element}"

        # Use add_group to add the polygon with proper indentation and optional comment
        self.add_group(
            group_content,
            group_id=polygon.id,
            group_class="hoverable",  # You might want to adjust this or make it configurable
            indent_level=indent_level,
            comment=polygon.comment,
        )

    def add_legend_column(
        self,
        items: List[Tuple[str, str]],
        title: str,
        x: int,
        y: int,
        width: int,
        height: int,
    ) -> None:
        """
        Add a legend column to the SVG.

        Args:
            items (List[Tuple[str, str]]): List of tuples with color code and label.
            title (str): Title of the legend.
            x (int): X position of the legend.
            y (int): Y position of the legend.
            width (int): Width of the color box in the legend.
            height (int): Height of each legend item.
        """
        self.add_text(x, y - height, title, font_weight="bold")
        for index, (color, label) in enumerate(items):
            self.add_rectangle(x, y + index * (height + 5), width, height, color)
            self.add_text(
                x + width + 10,
                y + index * (height + 5) + height / 2,
                label,
                center_v=True,
            )

    def add_text(
        self,
        x: int,
        y: int,
        text: str,
        fill: str = "black",
        font_weight: str = "normal",
        text_anchor: str = "start",
        transform: str = "",
        center_v: bool = False,
        text_class: str = "noclick",
        indent_level: int = 1,
    ) -> None:
        """
        Add text to the SVG.

        Args:
            x (int): X position of the text.
            y (int): Y position of the text.
            text(str): Text content.
            fill (str, optional): Fill color of the text. Defaults to "black".
            font_weight (str, optional): Font weight (normal, bold, etc.). Defaults to "normal".
            text_anchor (str, optional): Text alignment (start, middle, end). Defaults to "start".
            indent_level(int): the indentation level to apply
            center_v (bool): If True, y as the vertical center of the text. Default is False.
            text_class(str): "noclick" by default so that elements below are clickable
            transform (str, optional): Transformation for the text (e.g., rotation). Defaults to an empty string.
        """
        text_obj = Text(text, self.config)
        if center_v:
            # y-offset adjustment to center the text vertically
            y -= text_obj.total_text_height / 2
            # adjust for the ascender / descender vertical font weighting
            y -= self.config.font_size * 0.5
        # Create a text element to hold the tspan elements
        # Only include the transform attribute if it is provided
        transform_attr = f'transform="{transform}" ' if transform else ""

        text_element = (
            f'\n{self.get_indent(indent_level)}<text class="{text_class}" x="{x}" y="{y}" fill="{fill}" '
            f'font-family="{self.config.font}" '
            f'font-size="{self.config.font_size}" '
            f'font-weight="{font_weight}" '
            f'text-anchor="{text_anchor}" '
            f'dominant-baseline="middle" '
            f"{transform_attr}>"
        )
        # Add tspan elements for each line
        for line in text_obj.lines:
            escaped_line = html.escape(line)
            text_element += f'\n{self.get_indent(indent_level+1)}<tspan x="{x}" dy="{self.config.line_height}">{escaped_line}</tspan>'

        text_element += f"\n{self.get_indent(indent_level)}</text>\n"
        self.add_element(text_element)

    def add_group(
        self,
        content: str,
        group_id: str = None,
        group_class: str = None,
        indent_level: int = 1,
        comment: str = None,
    ):
        """
        Add a group of elements to the SVG.

        Args:
            content (str): SVG content to be grouped.
            group_id (str, optional): ID for the group.
            group_class (str, optional): Class for the group.
            indent_level (int): Indentation level for the group.
        """
        group_attrs = []
        if group_id:
            group_attrs.append(f'id="{group_id}"')
        if group_class:
            group_attrs.append(f'class="{group_class}"')
        attrs_str = " ".join(group_attrs)
        indented_content = "\n".join(
            f"{self.get_indent(indent_level + 1)}{line}"
            for line in content.strip().split("\n")
        )
        group_str = f"""{self.get_indent(indent_level)}<g {attrs_str}>
{indented_content}
{self.get_indent(indent_level)}</g>
"""
        self.add_element(group_str, indent_level=indent_level, comment=comment)

    def add_donut_segment(
        self,
        config: SVGNodeConfig,
        segment: DonutSegment,
    ) -> None:
        """
        Add a donut segment to the SVG.

        Args:
            config (SVGNodeConfig): Configuration for the donut segment.
            segment(DonutSegment)
        """
        color = config.fill if config.fill else self.config.default_color
        stroke_color = config.color if config.color else "black"

        path_str = self.get_donut_path(segment)

        # Assemble the path and title elements
        path_element = (
            f'<path d="{path_str}" stroke="{stroke_color}" fill="{color}" />\n'
        )
        if config.title:
            escaped_title = html.escape(config.title)  # Escape special characters

            title_element = f"<title>{escaped_title}</title>"
        else:
            title_element = ""
        # Combine path and title into one string without adding indentation here
        group_content = f"{path_element}{title_element}"

        # Check if the segment should be shown as a popup
        if config.show_as_popup:
            # Add JavaScript to handle popup logic
            onclick_action = f"onclick=\"showPopup('{config.url}', evt,this)\""
            group_content = f"<g {onclick_action}>{group_content}</g>"
        elif config.url:
            # Regular link behavior
            group_content = (
                f'<a xlink:href="{config.url}" target="_blank">{group_content}</a>'
            )

        # Use add_group to add the pie segment with proper indentation
        self.add_group(
            group_content,
            group_id=config.id,
            group_class=config.element_class,
            indent_level=2,
            comment=config.comment,
        )

    def add_text_to_donut_segment(
        self,
        segment: DonutSegment,
        text: str,
        direction: str = "horizontal",
        color: str = "white",
        text_class: str = "noclick",
        indent_level: int = 1,
    ) -> None:
        """
        Add text to a donut segment with various direction options.

        Args:
            segment (DonutSegment): The donut segment to which text will be added.
            text (str): The text content to be added.
            direction (str): The direction in which the text should be drawn.
                             Options are "horizontal", "angled", or "curved".
            color (str): The color of the text. Default is "white".
        """
        if direction in ["horizontal", "angled"]:
            # Calculate position for horizontal or angled text
            # Use the get_arc method to find the position for horizontal or angled text
            mid_arc = segment.get_arc(radial_offset=0.5)  # Get the mid-arc
            text_x, text_y = mid_arc.middle_x, mid_arc.middle_y

            # Adjust text anchor and rotation for better readability
            transform = ""
            if direction == "angled":
                mid_angle = segment.relative_angle(0.5)
                rotation_angle = self.get_text_rotation(mid_angle)
                transform = f"rotate({rotation_angle}, {text_x}, {text_y})"

            # Add text using the add_text method
            self.add_text(
                x=text_x,
                y=text_y,
                text=text,
                fill=color,
                font_weight="normal",
                indent_level=indent_level,
                transform=transform,
                text_anchor="middle",
                center_v=True,
            )

        elif direction == "curved":
            text_obj = Text(text, self.config)

            for i, line in enumerate(text_obj.lines):
                radial_offset = 1 - ((i + 1) / (text_obj.line_count + 1))
                # Create a path for the text to follow
                path_id = f"path{segment.start_angle}-{segment.end_angle}-{i}"
                path_d = self.get_donut_path(
                    segment, middle_arc=True, radial_offset=radial_offset
                )
                self.add_element(
                    f'<path id="{path_id}" d="{path_d}" fill="none" stroke="none" />'
                )

                text_tag = f"""<text class="{text_class}" fill="{color}" font-family="{self.config.font}" font-size="{self.config.font_size}">"""
                self.add_element(text_tag, indent_level=indent_level)
                text_path = f"""<textPath  xlink:href="#{path_id}" startOffset="50%" dominant-baseline="middle" text-anchor="middle">{html.escape(line)}</textPath>"""
                self.add_element(text_path, indent_level=indent_level + 1)
                self.add_element("</text>", indent_level=indent_level)
        else:
            raise ValueError(f"invalid direction {direction}")

    def get_java_script(self) -> str:
        """
        get the java script code for interactive behavior
        """
        popup_script = """
    <script>
         function showPopup(url, evt,element) {
            // show a Popup fetching html content from the given url
            // for the given element
            // Handle the selection of the popup element
            selectPopupElement(element);
            var popup = document.getElementById('dcm-svg-popup');
            var iframe = document.getElementById('popup-iframe');
            var svgRect = evt.target.getBoundingClientRect();
            var svg = document.querySelector('svg');
            var svgPoint = svg.createSVGPoint();
            svgPoint.x = evt.clientX - svgRect.left;
            svgPoint.y = evt.clientY - svgRect.top;

            // Position the popup near the click event
            popup.setAttribute('x', svgPoint.x);
            popup.setAttribute('y', svgPoint.y);
            // Set the iframe src and make the popup visible
            iframe.setAttribute('src', url);
            popup.setAttribute('visibility', 'visible');
        }

        function selectPopupElement(element) {
            var popup = document.getElementById('dcm-svg-popup');

            // Deselect the current element if there is one
            if (popup.currentElement) {
                popup.currentElement.classList.remove('selected');
            }

            // Select the new element
            if (element) {
                element.classList.add('selected');
                popup.currentElement = element; // Update the reference to the currently selected element
            } else {
                popup.currentElement = null; // Clear the reference if no element is passed
            }
        }

        function closePopup() {
            var popup = document.getElementById('dcm-svg-popup');
            popup.setAttribute('visibility', 'hidden');
            // Deselect the element when the popup is closed
            selectPopupElement(null);
        }
    </script>
    """
        return popup_script

    def get_svg_markup(self, with_java_script: bool = False) -> str:
        """
        Generate the complete SVG markup.

        Args:
            with_java_script(bool): if True(default) the javascript code is included otherwise
            it's available via the get_java_script function

        Returns:
            str: String containing the complete SVG markup.
        """
        # Get current date and time
        now = datetime.now()
        formatted_now = now.strftime("%Y-%m-%d %H:%M:%S")
        header = (
            f"<!-- generated by dcm https://github.com/WolfgangFahl/dcm at {formatted_now} -->\n"
            f'<svg xmlns="http://www.w3.org/2000/svg" '
            f'xmlns:xlink="http://www.w3.org/1999/xlink" '
            f'width="{self.width}" height="{self.config.total_height}">\n'
        )
        popup = (
            """
        <!-- Add a foreignObject for the popup -->
<foreignObject id="dcm-svg-popup" class="popup" width="500" height="354" x="150" y="260" visibility="hidden">
    <body xmlns="http://www.w3.org/1999/xhtml">
        <!-- Content of your popup goes here -->
        <div class="popup" style="background-color: white; border: 1px solid black; padding: 10px; box-sizing: border-box; width: 500px; height: 354px; position: relative;">
            <span onclick="closePopup()" class="close-btn">ⓧ</span>
            <iframe id="popup-iframe" width="100%" height="100%" frameborder="0"></iframe>
        </div>
    </body>
</foreignObject>
"""
            if self.config.with_popup
            else ""
        )
        styles = self.get_svg_style()
        body = "".join(self.elements)
        footer = "</svg>"
        java_script = self.get_java_script() if with_java_script else ""
        svg_markup = f"{header}{java_script}{styles}{body}{popup}{footer}"
        return svg_markup

    def save(self, filename: str):
        """
        Save the SVG markup to a file.

        Args:
            filename (str): Filename to save the SVG markup.
        """
        with open(filename, "w") as file:
            file.write(self.get_svg_markup())

__init__(config=None)

Initialize SVG object with given configuration.

Parameters:

Name Type Description Default
config SVGConfig

Configuration for SVG generation.

None
Source code in dcm/svg.py
227
228
229
230
231
232
233
234
235
236
237
238
def __init__(self, config: SVGConfig = None):
    """
    Initialize SVG object with given configuration.

    Args:
        config (SVGConfig): Configuration for SVG generation.
    """
    self.config = config if config else SVGConfig()
    self.width = self.config.width
    self.height = self.config.height
    self.elements = []
    self.indent = self.config.indent

add_circle(config)

Add a circle element to the SVG, optionally making it clickable and with a hover effect.

Parameters:

Name Type Description Default
config SVGNodeConfig

Configuration for the circle element.

required
Source code in dcm/svg.py
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
    def add_circle(self, config: SVGNodeConfig):
        """
        Add a circle element to the SVG, optionally making it clickable and with a hover effect.

        Args:
            config (SVGNodeConfig): Configuration for the circle element.
        """
        color = config.fill if config.fill else self.config.default_color
        circle_element = f'<circle cx="{config.x}" cy="{config.y}" r="{config.width}" fill="{color}" class="{config.element_class}" />'

        # If URL is provided, wrap the circle in an anchor tag to make it clickable
        if config.url:
            circle_indent = self.get_indent(config.indent_level + 1)
            circle_element = f"""<a xlink:href="{config.url}" target="_blank">
{circle_indent}{circle_element}
</a>"""

        # Use add_group to add the circle element with proper indentation
        self.add_group(
            circle_element,
            group_id=config.id,
            group_class=config.element_class,
            indent_level=config.indent_level,
            comment=config.comment,
        )

add_donut_segment(config, segment)

Add a donut segment to the SVG.

Parameters:

Name Type Description Default
config SVGNodeConfig

Configuration for the donut segment.

required
Source code in dcm/svg.py
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
def add_donut_segment(
    self,
    config: SVGNodeConfig,
    segment: DonutSegment,
) -> None:
    """
    Add a donut segment to the SVG.

    Args:
        config (SVGNodeConfig): Configuration for the donut segment.
        segment(DonutSegment)
    """
    color = config.fill if config.fill else self.config.default_color
    stroke_color = config.color if config.color else "black"

    path_str = self.get_donut_path(segment)

    # Assemble the path and title elements
    path_element = (
        f'<path d="{path_str}" stroke="{stroke_color}" fill="{color}" />\n'
    )
    if config.title:
        escaped_title = html.escape(config.title)  # Escape special characters

        title_element = f"<title>{escaped_title}</title>"
    else:
        title_element = ""
    # Combine path and title into one string without adding indentation here
    group_content = f"{path_element}{title_element}"

    # Check if the segment should be shown as a popup
    if config.show_as_popup:
        # Add JavaScript to handle popup logic
        onclick_action = f"onclick=\"showPopup('{config.url}', evt,this)\""
        group_content = f"<g {onclick_action}>{group_content}</g>"
    elif config.url:
        # Regular link behavior
        group_content = (
            f'<a xlink:href="{config.url}" target="_blank">{group_content}</a>'
        )

    # Use add_group to add the pie segment with proper indentation
    self.add_group(
        group_content,
        group_id=config.id,
        group_class=config.element_class,
        indent_level=2,
        comment=config.comment,
    )

add_element(element, indent_level=1, comment=None)

Add an SVG element to the elements list with proper indentation.

Parameters:

Name Type Description Default
element str

SVG element to be added.

required
indent_level int

Indentation level for the element.

1
comment(str)

optional comment to add

required
Source code in dcm/svg.py
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
def add_element(self, element: str, indent_level: int = 1, comment: str = None):
    """
    Add an SVG element to the elements list with proper indentation.

    Args:
        element (str): SVG element to be added.
        indent_level (int): Indentation level for the element.
        comment(str): optional comment to add
    """
    base_indent = self.get_indent(indent_level)
    if comment:
        indented_comment = f"{base_indent}<!-- {comment} -->\n"
        self.elements.append(indented_comment)
    indented_element = f"{base_indent}{element}\n"
    self.elements.append(indented_element)

add_group(content, group_id=None, group_class=None, indent_level=1, comment=None)

Add a group of elements to the SVG.

Parameters:

Name Type Description Default
content str

SVG content to be grouped.

required
group_id str

ID for the group.

None
group_class str

Class for the group.

None
indent_level int

Indentation level for the group.

1
Source code in dcm/svg.py
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
    def add_group(
        self,
        content: str,
        group_id: str = None,
        group_class: str = None,
        indent_level: int = 1,
        comment: str = None,
    ):
        """
        Add a group of elements to the SVG.

        Args:
            content (str): SVG content to be grouped.
            group_id (str, optional): ID for the group.
            group_class (str, optional): Class for the group.
            indent_level (int): Indentation level for the group.
        """
        group_attrs = []
        if group_id:
            group_attrs.append(f'id="{group_id}"')
        if group_class:
            group_attrs.append(f'class="{group_class}"')
        attrs_str = " ".join(group_attrs)
        indented_content = "\n".join(
            f"{self.get_indent(indent_level + 1)}{line}"
            for line in content.strip().split("\n")
        )
        group_str = f"""{self.get_indent(indent_level)}<g {attrs_str}>
{indented_content}
{self.get_indent(indent_level)}</g>
"""
        self.add_element(group_str, indent_level=indent_level, comment=comment)

add_legend_column(items, title, x, y, width, height)

Add a legend column to the SVG.

Parameters:

Name Type Description Default
items List[Tuple[str, str]]

List of tuples with color code and label.

required
title str

Title of the legend.

required
x int

X position of the legend.

required
y int

Y position of the legend.

required
width int

Width of the color box in the legend.

required
height int

Height of each legend item.

required
Source code in dcm/svg.py
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
def add_legend_column(
    self,
    items: List[Tuple[str, str]],
    title: str,
    x: int,
    y: int,
    width: int,
    height: int,
) -> None:
    """
    Add a legend column to the SVG.

    Args:
        items (List[Tuple[str, str]]): List of tuples with color code and label.
        title (str): Title of the legend.
        x (int): X position of the legend.
        y (int): Y position of the legend.
        width (int): Width of the color box in the legend.
        height (int): Height of each legend item.
    """
    self.add_text(x, y - height, title, font_weight="bold")
    for index, (color, label) in enumerate(items):
        self.add_rectangle(x, y + index * (height + 5), width, height, color)
        self.add_text(
            x + width + 10,
            y + index * (height + 5) + height / 2,
            label,
            center_v=True,
        )

add_polygon(polygon, indent_level=1)

Add a polygon to the SVG.

Parameters:

Name Type Description Default
polygon Polygon

The polygon to add to the SVG.

required
indent_level int

Indentation level for the element.

1
Source code in dcm/svg.py
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
def add_polygon(self, polygon: Polygon, indent_level: int = 1):
    """
    Add a polygon to the SVG.

    Args:
        polygon (Polygon): The polygon to add to the SVG.
        indent_level (int): Indentation level for the element.
    """
    points_str = " ".join(f"{x},{y}" for x, y in polygon.points)
    fill_color = polygon.fill if polygon.fill else self.config.default_color
    opacity = polygon.opacity
    stroke_color = polygon.color if polygon.color else "black"
    stroke_width = polygon.stroke_width

    polygon_element = (
        f'<polygon points="{points_str}" '
        f'stroke="{stroke_color}" stroke-width="{stroke_width}" '
        f'fill="{fill_color}" fill-opacity="{opacity}" />\n'
    )

    # If a title (tooltip) is provided, create a title element
    title_element = (
        f"<title>{html.escape(polygon.title)}</title>\n" if polygon.title else ""
    )

    # Combine polygon and title into one string without adding indentation here
    group_content = f"{polygon_element}{title_element}"

    # Use add_group to add the polygon with proper indentation and optional comment
    self.add_group(
        group_content,
        group_id=polygon.id,
        group_class="hoverable",  # You might want to adjust this or make it configurable
        indent_level=indent_level,
        comment=polygon.comment,
    )

add_rectangle(x, y, width, height, fill=None, indent_level=1)

Add a rectangle element to the SVG.

Parameters:

Name Type Description Default
x int

X-coordinate of the rectangle's top-left corner.

required
y int

Y-coordinate of the rectangle's top-left corner.

required
width int

Width of the rectangle.

required
height int

Height of the rectangle.

required
fill str

Fill color of the rectangle. Defaults to the default color.

None
indent_level int

Indentation level for the rectangle.

1
Source code in dcm/svg.py
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
def add_rectangle(
    self,
    x: int,
    y: int,
    width: int,
    height: int,
    fill: str = None,
    indent_level: int = 1,
):
    """
    Add a rectangle element to the SVG.

    Args:
        x (int): X-coordinate of the rectangle's top-left corner.
        y (int): Y-coordinate of the rectangle's top-left corner.
        width (int): Width of the rectangle.
        height (int): Height of the rectangle.
        fill (str, optional): Fill color of the rectangle. Defaults to the default color.
        indent_level (int): Indentation level for the rectangle.
    """
    color = fill if fill else self.config.default_color
    rect = f'<rect x="{x}" y="{y}" width="{width}" height="{height}" fill="{color}" />\n'
    self.add_element(rect, indent_level=indent_level)

add_text(x, y, text, fill='black', font_weight='normal', text_anchor='start', transform='', center_v=False, text_class='noclick', indent_level=1)

Add text to the SVG.

Parameters:

Name Type Description Default
x int

X position of the text.

required
y int

Y position of the text.

required
text(str)

Text content.

required
fill str

Fill color of the text. Defaults to "black".

'black'
font_weight str

Font weight (normal, bold, etc.). Defaults to "normal".

'normal'
text_anchor str

Text alignment (start, middle, end). Defaults to "start".

'start'
indent_level(int)

the indentation level to apply

required
center_v bool

If True, y as the vertical center of the text. Default is False.

False
text_class(str)

"noclick" by default so that elements below are clickable

required
transform str

Transformation for the text (e.g., rotation). Defaults to an empty string.

''
Source code in dcm/svg.py
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
def add_text(
    self,
    x: int,
    y: int,
    text: str,
    fill: str = "black",
    font_weight: str = "normal",
    text_anchor: str = "start",
    transform: str = "",
    center_v: bool = False,
    text_class: str = "noclick",
    indent_level: int = 1,
) -> None:
    """
    Add text to the SVG.

    Args:
        x (int): X position of the text.
        y (int): Y position of the text.
        text(str): Text content.
        fill (str, optional): Fill color of the text. Defaults to "black".
        font_weight (str, optional): Font weight (normal, bold, etc.). Defaults to "normal".
        text_anchor (str, optional): Text alignment (start, middle, end). Defaults to "start".
        indent_level(int): the indentation level to apply
        center_v (bool): If True, y as the vertical center of the text. Default is False.
        text_class(str): "noclick" by default so that elements below are clickable
        transform (str, optional): Transformation for the text (e.g., rotation). Defaults to an empty string.
    """
    text_obj = Text(text, self.config)
    if center_v:
        # y-offset adjustment to center the text vertically
        y -= text_obj.total_text_height / 2
        # adjust for the ascender / descender vertical font weighting
        y -= self.config.font_size * 0.5
    # Create a text element to hold the tspan elements
    # Only include the transform attribute if it is provided
    transform_attr = f'transform="{transform}" ' if transform else ""

    text_element = (
        f'\n{self.get_indent(indent_level)}<text class="{text_class}" x="{x}" y="{y}" fill="{fill}" '
        f'font-family="{self.config.font}" '
        f'font-size="{self.config.font_size}" '
        f'font-weight="{font_weight}" '
        f'text-anchor="{text_anchor}" '
        f'dominant-baseline="middle" '
        f"{transform_attr}>"
    )
    # Add tspan elements for each line
    for line in text_obj.lines:
        escaped_line = html.escape(line)
        text_element += f'\n{self.get_indent(indent_level+1)}<tspan x="{x}" dy="{self.config.line_height}">{escaped_line}</tspan>'

    text_element += f"\n{self.get_indent(indent_level)}</text>\n"
    self.add_element(text_element)

add_text_to_donut_segment(segment, text, direction='horizontal', color='white', text_class='noclick', indent_level=1)

Add text to a donut segment with various direction options.

Parameters:

Name Type Description Default
segment DonutSegment

The donut segment to which text will be added.

required
text str

The text content to be added.

required
direction str

The direction in which the text should be drawn. Options are "horizontal", "angled", or "curved".

'horizontal'
color str

The color of the text. Default is "white".

'white'
Source code in dcm/svg.py
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
def add_text_to_donut_segment(
    self,
    segment: DonutSegment,
    text: str,
    direction: str = "horizontal",
    color: str = "white",
    text_class: str = "noclick",
    indent_level: int = 1,
) -> None:
    """
    Add text to a donut segment with various direction options.

    Args:
        segment (DonutSegment): The donut segment to which text will be added.
        text (str): The text content to be added.
        direction (str): The direction in which the text should be drawn.
                         Options are "horizontal", "angled", or "curved".
        color (str): The color of the text. Default is "white".
    """
    if direction in ["horizontal", "angled"]:
        # Calculate position for horizontal or angled text
        # Use the get_arc method to find the position for horizontal or angled text
        mid_arc = segment.get_arc(radial_offset=0.5)  # Get the mid-arc
        text_x, text_y = mid_arc.middle_x, mid_arc.middle_y

        # Adjust text anchor and rotation for better readability
        transform = ""
        if direction == "angled":
            mid_angle = segment.relative_angle(0.5)
            rotation_angle = self.get_text_rotation(mid_angle)
            transform = f"rotate({rotation_angle}, {text_x}, {text_y})"

        # Add text using the add_text method
        self.add_text(
            x=text_x,
            y=text_y,
            text=text,
            fill=color,
            font_weight="normal",
            indent_level=indent_level,
            transform=transform,
            text_anchor="middle",
            center_v=True,
        )

    elif direction == "curved":
        text_obj = Text(text, self.config)

        for i, line in enumerate(text_obj.lines):
            radial_offset = 1 - ((i + 1) / (text_obj.line_count + 1))
            # Create a path for the text to follow
            path_id = f"path{segment.start_angle}-{segment.end_angle}-{i}"
            path_d = self.get_donut_path(
                segment, middle_arc=True, radial_offset=radial_offset
            )
            self.add_element(
                f'<path id="{path_id}" d="{path_d}" fill="none" stroke="none" />'
            )

            text_tag = f"""<text class="{text_class}" fill="{color}" font-family="{self.config.font}" font-size="{self.config.font_size}">"""
            self.add_element(text_tag, indent_level=indent_level)
            text_path = f"""<textPath  xlink:href="#{path_id}" startOffset="50%" dominant-baseline="middle" text-anchor="middle">{html.escape(line)}</textPath>"""
            self.add_element(text_path, indent_level=indent_level + 1)
            self.add_element("</text>", indent_level=indent_level)
    else:
        raise ValueError(f"invalid direction {direction}")

get_donut_path(segment, radial_offset=0.5, middle_arc=False)

Create an SVG path definition for an arc using the properties of a DonutSegment.

Parameters:

Name Type Description Default
segment DonutSegment

The segment for which to create the path.

required
radial_offset(float)

0 to 1 - useable in middle_arc mode

required
middle_arc(bool)

if True get the middle arc

required

Returns:

Name Type Description
str str

SVG path definition string for the full donut segment or the middle_arc if middle_arc is set to true.

Source code in dcm/svg.py
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
def get_donut_path(
    self,
    segment: DonutSegment,
    radial_offset: float = 0.5,
    middle_arc: bool = False,
) -> str:
    """
    Create an SVG path definition for an arc using the properties of a DonutSegment.

    Args:
        segment (DonutSegment): The segment for which to create the path.
        radial_offset(float): 0 to 1 - useable in middle_arc mode
        middle_arc(bool): if True get the middle arc

    Returns:
        str: SVG path definition string for the full donut segment or the middle_arc if middle_arc is set to true.
    """
    if middle_arc:
        arc = segment.get_arc(radial_offset=radial_offset)

        # Create the path for the middle arc
        path_str = (
            f"M {arc.start_x} {arc.start_y} "  # Move to start of middle arc
            f"A {arc.radius} {arc.radius} 0 {segment.large_arc_flag} 1 {arc.end_x} {arc.end_y}"
        )
    else:
        outer_arc = segment.get_arc(radial_offset=1)
        inner_arc = segment.get_arc(radial_offset=0)
        path_str = (
            f"M {inner_arc.start_x} {inner_arc.start_y} "  # Move to start of inner arc
            f"L {outer_arc.start_x} {outer_arc.start_y} "  # Line to start of outer arc
            f"A {segment.outer_radius} {segment.outer_radius} 0 {segment.large_arc_flag} 1 {outer_arc.end_x} {outer_arc.end_y} "  # Outer arc
            f"L {inner_arc.end_x} {inner_arc.end_y} "  # Line to end of inner arc
            f"A {segment.inner_radius} {segment.inner_radius} 0 {segment.large_arc_flag} 0 {inner_arc.start_x} {inner_arc.start_y} "  # Inner arc (reverse)
            "Z"
        )

    return path_str

get_indent(level)

get the indentation for the given level

Source code in dcm/svg.py
240
241
242
243
244
245
def get_indent(self, level) -> str:
    """
    get the indentation for the given level
    """
    indentation = f"{self.indent * level}"
    return indentation

get_java_script()

get the java script code for interactive behavior

Source code in dcm/svg.py
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
def get_java_script(self) -> str:
    """
    get the java script code for interactive behavior
    """
    popup_script = """
<script>
     function showPopup(url, evt,element) {
        // show a Popup fetching html content from the given url
        // for the given element
        // Handle the selection of the popup element
        selectPopupElement(element);
        var popup = document.getElementById('dcm-svg-popup');
        var iframe = document.getElementById('popup-iframe');
        var svgRect = evt.target.getBoundingClientRect();
        var svg = document.querySelector('svg');
        var svgPoint = svg.createSVGPoint();
        svgPoint.x = evt.clientX - svgRect.left;
        svgPoint.y = evt.clientY - svgRect.top;

        // Position the popup near the click event
        popup.setAttribute('x', svgPoint.x);
        popup.setAttribute('y', svgPoint.y);
        // Set the iframe src and make the popup visible
        iframe.setAttribute('src', url);
        popup.setAttribute('visibility', 'visible');
    }

    function selectPopupElement(element) {
        var popup = document.getElementById('dcm-svg-popup');

        // Deselect the current element if there is one
        if (popup.currentElement) {
            popup.currentElement.classList.remove('selected');
        }

        // Select the new element
        if (element) {
            element.classList.add('selected');
            popup.currentElement = element; // Update the reference to the currently selected element
        } else {
            popup.currentElement = null; // Clear the reference if no element is passed
        }
    }

    function closePopup() {
        var popup = document.getElementById('dcm-svg-popup');
        popup.setAttribute('visibility', 'hidden');
        // Deselect the element when the popup is closed
        selectPopupElement(null);
    }
</script>
"""
    return popup_script

get_svg_markup(with_java_script=False)

Generate the complete SVG markup.

Parameters:

Name Type Description Default
with_java_script(bool)

if True(default) the javascript code is included otherwise

required

Returns:

Name Type Description
str str

String containing the complete SVG markup.

Source code in dcm/svg.py
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
    def get_svg_markup(self, with_java_script: bool = False) -> str:
        """
        Generate the complete SVG markup.

        Args:
            with_java_script(bool): if True(default) the javascript code is included otherwise
            it's available via the get_java_script function

        Returns:
            str: String containing the complete SVG markup.
        """
        # Get current date and time
        now = datetime.now()
        formatted_now = now.strftime("%Y-%m-%d %H:%M:%S")
        header = (
            f"<!-- generated by dcm https://github.com/WolfgangFahl/dcm at {formatted_now} -->\n"
            f'<svg xmlns="http://www.w3.org/2000/svg" '
            f'xmlns:xlink="http://www.w3.org/1999/xlink" '
            f'width="{self.width}" height="{self.config.total_height}">\n'
        )
        popup = (
            """
        <!-- Add a foreignObject for the popup -->
<foreignObject id="dcm-svg-popup" class="popup" width="500" height="354" x="150" y="260" visibility="hidden">
    <body xmlns="http://www.w3.org/1999/xhtml">
        <!-- Content of your popup goes here -->
        <div class="popup" style="background-color: white; border: 1px solid black; padding: 10px; box-sizing: border-box; width: 500px; height: 354px; position: relative;">
            <span onclick="closePopup()" class="close-btn">ⓧ</span>
            <iframe id="popup-iframe" width="100%" height="100%" frameborder="0"></iframe>
        </div>
    </body>
</foreignObject>
"""
            if self.config.with_popup
            else ""
        )
        styles = self.get_svg_style()
        body = "".join(self.elements)
        footer = "</svg>"
        java_script = self.get_java_script() if with_java_script else ""
        svg_markup = f"{header}{java_script}{styles}{body}{popup}{footer}"
        return svg_markup

get_svg_style()

Define styles for SVG elements.

Returns:

Name Type Description
str str

String containing style definitions for SVG.

Source code in dcm/svg.py
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def get_svg_style(self) -> str:
    """
    Define styles for SVG elements.

    Returns:
        str: String containing style definitions for SVG.
    """
    style = (
        f"{self.indent}<style>\n"
        f"{self.indent * 2}.hoverable {{ cursor: pointer; fill-opacity: 1; stroke: black; stroke-width: 0.5; }}\n"
        f"{self.indent * 2}.hoverable:hover {{ fill-opacity: 0.7; }}\n"
        f"{self.indent * 2}.selected {{ fill-opacity: 0.5; stroke: blue !important; stroke-width: 1.5;}}\n"
        f"{self.indent * 2}.noclick {{ pointer-events: none; }}\n"  # style for non-clickable text
    )

    if self.config.with_popup:
        style += (
            f"{self.indent * 2}.popup {{\n"
            f"{self.indent * 3}border: 2px solid black;\n"
            f"{self.indent * 3}border-radius: 15px;\n"
            f"{self.indent * 3}overflow: auto;\n"  # changed to 'auto' to allow scrolling only if needed
            f"{self.indent * 3}background: white;\n"
            f"{self.indent * 3}box-sizing: border-box;\n"  # ensures padding and border are included
            f"{self.indent * 3}padding: 10px;\n"  # optional padding inside the popup
            f"{self.indent * 3}height: 100%;\n"  # adjusts height relative to foreignObject
            f"{self.indent * 3}width: 100%;\n"  # adjusts width relative to foreignObject
            f"{self.indent * 2}}}\n"
            f"{self.indent * 2}.close-btn {{\n"  # style for the close button
            f"{self.indent * 3}cursor: pointer;\n"
            f"{self.indent * 3}position: absolute;\n"
            f"{self.indent * 3}top: 0;\n"
            f"{self.indent * 3}right: 0;\n"
            f"{self.indent * 3}padding: 5px;\n"
            f"{self.indent * 3}font-size: 20px;\n"
            f"{self.indent * 3}user-select: none;\n"  # prevents text selection on click
            f"{self.indent * 2}}}\n"
        )

    style += f"{self.indent}</style>\n"
    return style

get_text_rotation(rotation_angle)

Adjusts the rotation angle for SVG text elements to ensure that the text is upright and readable in a circular chart. The text will be rotated by 180 degrees if it is in the lower half of the chart (between 90 and 270 degrees).

Parameters:

Name Type Description Default
rotation_angle float

The initial rotation angle of the text element.

required

Returns:

Name Type Description
float float

The adjusted rotation angle for the text element.

Source code in dcm/svg.py
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
def get_text_rotation(self, rotation_angle: float) -> float:
    """
    Adjusts the rotation angle for SVG text elements to ensure that the text
    is upright and readable in a circular chart. The text will be rotated
    by 180 degrees if it is in the lower half of the chart (between 90 and 270 degrees).

    Args:
        rotation_angle (float): The initial rotation angle of the text element.

    Returns:
        float: The adjusted rotation angle for the text element.
    """
    # In the bottom half of the chart (90 to 270 degrees), the text
    # would appear upside down, so we rotate it by 180 degrees.
    if 90 <= rotation_angle < 270:
        rotation_angle -= 180

    # Return the adjusted angle. No adjustment is needed for the
    # top half of the chart as the text is already upright.
    return rotation_angle

get_text_width(text)

Estimate the width of a text string in the SVG based on the font size and font name.

Parameters:

Name Type Description Default
text str

The text content.

required

Returns:

Name Type Description
int int

The estimated width of the text in pixels.

Source code in dcm/svg.py
288
289
290
291
292
293
294
295
296
297
298
299
300
def get_text_width(self, text: str) -> int:
    """
    Estimate the width of a text string in the SVG based on the font size and font name.

    Args:
        text (str): The text content.

    Returns:
        int: The estimated width of the text in pixels.
    """
    average_char_width_factor = 0.6
    average_char_width = average_char_width_factor * self.config.font_size
    return int(average_char_width * len(text))

save(filename)

Save the SVG markup to a file.

Parameters:

Name Type Description Default
filename str

Filename to save the SVG markup.

required
Source code in dcm/svg.py
797
798
799
800
801
802
803
804
805
def save(self, filename: str):
    """
    Save the SVG markup to a file.

    Args:
        filename (str): Filename to save the SVG markup.
    """
    with open(filename, "w") as file:
        file.write(self.get_svg_markup())

SVGConfig dataclass

Configuration class for SVG generation.

Attributes:

Name Type Description
width int

Width of the SVG canvas in pixels.

height int

Height of the SVG canvas in pixels.

legend_height int

Height reserved for the legend in pixels.

font str

Font family for text elements.

font_size int

Font size in points for text elements.

indent str

Indentation string, default is two spaces.

default_color str

Default color code for SVG elements.

with_pop(bool) str

if True support popup javascript functionality

Source code in dcm/svg.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@dataclass
class SVGConfig:
    """
    Configuration class for SVG generation.

    Attributes:
        width (int): Width of the SVG canvas in pixels.
        height (int): Height of the SVG canvas in pixels.
        legend_height (int): Height reserved for the legend in pixels.
        font (str): Font family for text elements.
        font_size (int): Font size in points for text elements.
        indent (str): Indentation string, default is two spaces.
        default_color (str): Default color code for SVG elements.
        with_pop(bool): if True support popup javascript functionality
    """

    width: int = 600
    height: int = 600
    legend_height: int = 150
    font: str = "Arial"
    font_size: int = 12
    indent: str = "  "
    default_color: str = "#C0C0C0"
    with_popup: bool = False

    @property
    def total_height(self) -> int:
        """
        Calculate total height of the SVG canvas including the legend.

        Returns:
            int: Total height of the SVG canvas.
        """
        return self.height + self.legend_height

    @property
    def line_height(self) -> float:
        # Calculate line height based on font size
        # You can adjust this multiplier as needed
        line_height = self.font_size * 1.2
        return line_height

total_height: int property

Calculate total height of the SVG canvas including the legend.

Returns:

Name Type Description
int int

Total height of the SVG canvas.

SVGNode dataclass

a generic SVG Node

Source code in dcm/svg.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
@dataclass
class SVGNode:
    """
    a generic SVG Node
    """

    indent_level: int = 1
    id: Optional[str] = None
    color: Optional[str] = (
        None  # Color of font or stroke use default color of config if None
    )
    stroke_width: float = 1.0  # Stroke width of the polygon lines
    opacity: float = 0.5  # Opacity of fill e.g. for polygons
    fill: Optional[str] = "black"  # Fill color for the segment
    title: Optional[str] = None  # Tooltip
    comment: Optional[str] = None

SVGNodeConfig dataclass

Bases: SVGNode

a single SVG Node configuration to display any element

Source code in dcm/svg.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
@dataclass
class SVGNodeConfig(SVGNode):
    """
    a single SVG Node configuration
    to display any element
    """

    x: float = 0.0
    y: float = 0.0
    width: Optional[float] = None
    height: Optional[float] = None
    element_type: Optional[str] = None
    url: Optional[str] = None
    show_as_popup: bool = False  # Flag to indicate if the link should opened as a popup
    element_class: Optional[str] = "hoverable"

Text dataclass

Class to handle text-related operations in SVG.

Attributes:

Name Type Description
text str

The text content.

config SVGConfig

Configuration for SVG generation.

Source code in dcm/svg.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
@dataclass
class Text:
    """
    Class to handle text-related operations in SVG.

    Attributes:
        text (str): The text content.
        config (SVGConfig): Configuration for SVG generation.
    """

    text: str
    config: SVGConfig
    lines: List[str] = field(init=False)

    def __post_init__(self):
        """
        Post-initialization processing to split the text into lines.
        """
        self.lines = self.text.strip().split("\n")

    @property
    def line_count(self) -> int:
        return len(self.lines)

    @property
    def total_text_height(self) -> float:
        """
        Calculate the total height of the text based on the number of lines and the line height.

        Returns:
            float: The total height of the text in pixels.
        """
        height = self.config.line_height * len(self.lines)
        return height

total_text_height: float property

Calculate the total height of the text based on the number of lines and the line height.

Returns:

Name Type Description
float float

The total height of the text in pixels.

__post_init__()

Post-initialization processing to split the text into lines.

Source code in dcm/svg.py
100
101
102
103
104
def __post_init__(self):
    """
    Post-initialization processing to split the text into lines.
    """
    self.lines = self.text.strip().split("\n")

version

Created on 2023-11-06

@author: wf

Version dataclass

Version handling for nicepdf

Source code in dcm/version.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@dataclass
class Version:
    """
    Version handling for nicepdf
    """

    name = "dcm"
    version = dcm.__version__
    date = "2023-11-06"
    updated = "2024-02-27"
    description = "python based visualization of dynamic competence maps"

    authors = "Wolfgang Fahl"

    doc_url = "https://wiki.bitplan.com/index.php/dcm"
    chat_url = "https://github.com/WolfgangFahl/dcm/discussions"
    cm_url = "https://github.com/WolfgangFahl/dcm"

    license = f"""Copyright 2023 contributors. All rights reserved.

  Licensed under the Apache License 2.0
  http://www.apache.org/licenses/LICENSE-2.0

  Distributed on an "AS IS" basis without warranties
  or conditions of any kind, either express or implied."""

    longDescription = f"""{name} version {version}
{description}

  Created by {authors} on {date} last updated {updated}"""

xapi

Created on 2024-01-24

Experience API xAPI support module @author: wf

XAPI

Experience API xAPI support class

Source code in dcm/xapi.py
14
15
16
17
18
19
20
21
22
23
24
25
26
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
71
72
73
74
75
76
77
78
79
80
class XAPI:
    """
    Experience API xAPI support class
    """

    def __init__(self):
        self.xapi_dict = {}

    def warn(self, msg):
        print(msg, file=sys.stderr)
        pass

    def to_learner(self, competence_tree: CompetenceTree) -> Learner:
        """
        Convert xapi_dict to a Learner with Achievements.
        Args:
            competence_tree (CompetenceTree): The competence tree to align the achievements with.
        Returns:
            Learner: A learner object with achievements mapped from xapi_dict.
        """
        # Assuming each entry in xapi_dict is an xAPI statement relevant to a learning activity
        achievements = []
        actor_id = None
        learner = None
        for entry in self.xapi_dict:
            stmt = entry.get("statement")
            if stmt:
                actor = stmt.get("actor")
                if actor:
                    # Extract necessary information from the xAPI statement
                    new_actor_id = actor["account"]["name"]
                    if actor_id is None:
                        actor_id = new_actor_id
                    else:
                        if new_actor_id != actor_id:
                            self.warn(f"invalid actor_id {new_actor_id} != {actor_id}")
                competence_path = stmt["context"]["extensions"][
                    "learningObjectMetadata"
                ]["competencePath"]
                score_scaled = stmt["result"]["score"]["scaled"]
                timestamp = stmt["timestamp"]

                # Create an Achievement instance
                achievement = Achievement(
                    path=competence_path,
                    level=int(
                        score_scaled * competence_tree.total_valid_levels
                    ),  # Convert scaled score to level
                    score=stmt["result"]["score"]["raw"],
                    date_assessed_iso=timestamp,
                )
                achievements.append(achievement)

        if actor_id:
            # Create a Learner instance with these achievements
            learner = Learner(learner_id=actor_id, achievements=achievements)
        else:
            self.warn("no learner / actor defined")
        return learner

    @classmethod
    def from_json(cls, json_file_path: str):
        xapi = cls()
        # Open and read the JSON file
        with open(json_file_path, "r") as json_file:
            xapi.xapi_dict = json.load(json_file)
        return xapi

to_learner(competence_tree)

Convert xapi_dict to a Learner with Achievements. Args: competence_tree (CompetenceTree): The competence tree to align the achievements with. Returns: Learner: A learner object with achievements mapped from xapi_dict.

Source code in dcm/xapi.py
26
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
71
72
def to_learner(self, competence_tree: CompetenceTree) -> Learner:
    """
    Convert xapi_dict to a Learner with Achievements.
    Args:
        competence_tree (CompetenceTree): The competence tree to align the achievements with.
    Returns:
        Learner: A learner object with achievements mapped from xapi_dict.
    """
    # Assuming each entry in xapi_dict is an xAPI statement relevant to a learning activity
    achievements = []
    actor_id = None
    learner = None
    for entry in self.xapi_dict:
        stmt = entry.get("statement")
        if stmt:
            actor = stmt.get("actor")
            if actor:
                # Extract necessary information from the xAPI statement
                new_actor_id = actor["account"]["name"]
                if actor_id is None:
                    actor_id = new_actor_id
                else:
                    if new_actor_id != actor_id:
                        self.warn(f"invalid actor_id {new_actor_id} != {actor_id}")
            competence_path = stmt["context"]["extensions"][
                "learningObjectMetadata"
            ]["competencePath"]
            score_scaled = stmt["result"]["score"]["scaled"]
            timestamp = stmt["timestamp"]

            # Create an Achievement instance
            achievement = Achievement(
                path=competence_path,
                level=int(
                    score_scaled * competence_tree.total_valid_levels
                ),  # Convert scaled score to level
                score=stmt["result"]["score"]["raw"],
                date_assessed_iso=timestamp,
            )
            achievements.append(achievement)

    if actor_id:
        # Create a Learner instance with these achievements
        learner = Learner(learner_id=actor_id, achievements=achievements)
    else:
        self.warn("no learner / actor defined")
    return learner