OctoPrint-Wrapped 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,257 @@
1
+ import json
2
+ import os
3
+ from typing import Optional
4
+
5
+ import flask
6
+ import octoprint.plugin
7
+ from flask_babel import gettext
8
+ from octoprint.access.permissions import Permissions
9
+ from octoprint.schema import BaseModel
10
+
11
+ WEEKDAYS = [
12
+ "Monday",
13
+ "Tuesday",
14
+ "Wednesday",
15
+ "Thursday",
16
+ "Friday",
17
+ "Saturday",
18
+ "Sunday",
19
+ ]
20
+ SECONDS_MINUTE = 60
21
+ SECONDS_HOUR = SECONDS_MINUTE * 60
22
+ SECONDS_DAY = SECONDS_HOUR * 24
23
+
24
+
25
+ class YearStats(BaseModel):
26
+ year: int
27
+ prints_completed: int
28
+ total_print_duration: str
29
+ longest_print: str
30
+ busiest_weekday: str
31
+ files_uploaded: int
32
+ octoprint_versions: int
33
+
34
+
35
+ class ApiResponse(BaseModel):
36
+ years: list[int]
37
+
38
+
39
+ class WrappedPlugin(
40
+ octoprint.plugin.AssetPlugin,
41
+ octoprint.plugin.BlueprintPlugin,
42
+ octoprint.plugin.SimpleApiPlugin,
43
+ octoprint.plugin.TemplatePlugin,
44
+ ):
45
+ ##~~ AssetPlugin mixin
46
+
47
+ def get_assets(self):
48
+ return {
49
+ "clientjs": ["clientjs/wrapped.js"],
50
+ "js": ["js/wrapped.js", "js/ko.src.svgtopng.js"],
51
+ }
52
+
53
+ ##~~ BlueprintPlugin mixin
54
+
55
+ def is_blueprint_csrf_protected(self):
56
+ return True
57
+
58
+ def is_protected(self):
59
+ return True
60
+
61
+ @octoprint.plugin.BlueprintPlugin.route("/<int:year>.svg", methods=["GET"])
62
+ def get_svg(self, year):
63
+ if (
64
+ not hasattr(Permissions, "PLUGIN_ACHIEVEMENTS_VIEW")
65
+ or not Permissions.PLUGIN_ACHIEVEMENTS_VIEW.can()
66
+ ):
67
+ flask.abort(403)
68
+
69
+ stats = self._get_year_stats(year)
70
+ if stats is None:
71
+ flask.abort(404)
72
+
73
+ response = flask.make_response(
74
+ flask.render_template("wrapped.svg.jinja2", **stats.model_dump(by_alias=True))
75
+ )
76
+ response.headers["Content-Type"] = "image/svg+xml"
77
+ return response
78
+
79
+ ##~~ SimpleApiPlugin mixin
80
+
81
+ def is_api_protected(self):
82
+ return True
83
+
84
+ def on_api_get(self, request):
85
+ if (
86
+ not hasattr(Permissions, "PLUGIN_ACHIEVEMENTS_VIEW")
87
+ or not Permissions.PLUGIN_ACHIEVEMENTS_VIEW.can()
88
+ ):
89
+ flask.abort(403)
90
+
91
+ response = ApiResponse(years=self._get_available_years())
92
+ return flask.jsonify(response.model_dump(by_alias=True))
93
+
94
+ ##~~ Softwareupdate hook
95
+
96
+ def get_update_information(self):
97
+ return {
98
+ "wrapped": {
99
+ "displayName": "OctoPrint Wrapped!",
100
+ "displayVersion": self._plugin_version,
101
+ # version check: github repository
102
+ "type": "github_release",
103
+ "user": "OctoPrint",
104
+ "repo": "OctoPrint-Wrapped",
105
+ "current": self._plugin_version,
106
+ # update method: pip
107
+ "pip": "https://github.com/OctoPrint/OctoPrint-Wrapped/archive/{target_version}.zip",
108
+ }
109
+ }
110
+
111
+ ##~~ TemplatePlugin mixin
112
+
113
+ def is_template_autoescaped(self):
114
+ return True
115
+
116
+ def get_template_configs(self):
117
+ return [
118
+ {
119
+ "type": "about",
120
+ "name": gettext("OctoPrint Wrapped!"),
121
+ "template": "wrapped_about.jinja2",
122
+ "custom_bindings": True,
123
+ },
124
+ {
125
+ "type": "navbar",
126
+ "template": "wrapped_navbar_wrapped.jinja2",
127
+ "custom_bindings": True,
128
+ },
129
+ {
130
+ "type": "navbar",
131
+ "template": "wrapped_navbar_snowfall.jinja2",
132
+ "custom_bindings": True,
133
+ },
134
+ ]
135
+
136
+ ##~~ helpers
137
+
138
+ def _get_year_stats_folder(self) -> Optional[str]:
139
+ folder = os.path.join(self.get_plugin_data_folder(), "..", "achievements")
140
+ if not os.path.isdir(folder):
141
+ return None
142
+ return folder
143
+
144
+ def _get_year_stats_file(self, year: int) -> Optional[str]:
145
+ folder = self._get_year_stats_folder()
146
+ if not folder:
147
+ return None
148
+
149
+ year_path = os.path.join(folder, f"{year}.json")
150
+ if not os.path.isfile(year_path):
151
+ return None
152
+
153
+ return year_path
154
+
155
+ def _get_available_years(self) -> list[int]:
156
+ import re
157
+
158
+ stats_folder = self._get_year_stats_folder()
159
+ if not stats_folder:
160
+ return []
161
+
162
+ pattern = re.compile(r"\d{4}.json")
163
+
164
+ years = []
165
+ for entry in os.scandir(stats_folder):
166
+ if not entry.is_file():
167
+ continue
168
+
169
+ if pattern.fullmatch(entry.name):
170
+ year, _ = os.path.splitext(entry.name)
171
+ years.append(int(year))
172
+
173
+ return years
174
+
175
+ def _get_year_stats(self, year: int) -> Optional[YearStats]:
176
+ stats_file = self._get_year_stats_file(year)
177
+ if not stats_file:
178
+ return None
179
+
180
+ try:
181
+ with open(stats_file) as f:
182
+ stats = json.load(f)
183
+ except Exception:
184
+ self._logger.exception(
185
+ f"Error while reading yearly stats for {year} from {stats_file}"
186
+ )
187
+ return None
188
+
189
+ try:
190
+ weekday_stats = stats.get("prints_started_per_weekday", {})
191
+ busiest = None
192
+ for key, value in weekday_stats.items():
193
+ if busiest is None or value > busiest[1]:
194
+ busiest = (key, value)
195
+
196
+ if busiest:
197
+ weekday = WEEKDAYS[int(busiest[0])]
198
+ else:
199
+ weekday = "-"
200
+
201
+ return YearStats(
202
+ year=year,
203
+ prints_completed=stats.get("prints_finished", 0),
204
+ total_print_duration=self._to_duration_days(
205
+ int(stats.get("print_duration_total", 0))
206
+ ),
207
+ longest_print=self._to_duration_hours(
208
+ int(stats.get("longest_print_duration", 0))
209
+ ),
210
+ busiest_weekday=weekday,
211
+ files_uploaded=int(stats.get("files_uploaded", 0)),
212
+ octoprint_versions=int(stats.get("seen_versions", 1)),
213
+ )
214
+ except Exception:
215
+ self._logger.exception(
216
+ f"Error while parsing yearly stats for {year} from {stats_file}"
217
+ )
218
+ return None
219
+
220
+ def _to_duration_days(self, seconds: int) -> str:
221
+ days = int(seconds / SECONDS_DAY)
222
+ seconds -= days * SECONDS_DAY
223
+
224
+ hours = int(seconds / SECONDS_HOUR)
225
+ seconds -= hours * SECONDS_HOUR
226
+
227
+ minutes = int(seconds / SECONDS_MINUTE)
228
+ seconds -= minutes * SECONDS_MINUTE
229
+
230
+ if days >= 100:
231
+ # strip the minutes to keep things fitting...
232
+ return f"{days}d {hours}h"
233
+ else:
234
+ return f"{days}d {hours}h {minutes}m"
235
+
236
+ def _to_duration_hours(self, seconds: int) -> str:
237
+ hours = int(seconds / SECONDS_HOUR)
238
+ seconds -= hours * SECONDS_HOUR
239
+
240
+ minutes = int(seconds / SECONDS_MINUTE)
241
+ seconds -= minutes * SECONDS_MINUTE
242
+
243
+ return f"{hours}h {minutes}m"
244
+
245
+
246
+ __plugin_name__ = "OctoPrint Wrapped!"
247
+ __plugin_pythoncompat__ = ">=3.9,<4" # Only Python 3
248
+
249
+
250
+ def __plugin_load__():
251
+ global __plugin_implementation__
252
+ __plugin_implementation__ = WrappedPlugin()
253
+
254
+ global __plugin_hooks__
255
+ __plugin_hooks__ = {
256
+ "octoprint.plugin.softwareupdate.check_config": __plugin_implementation__.get_update_information,
257
+ }
@@ -0,0 +1,27 @@
1
+ (function (global, factory) {
2
+ if (typeof define === "function" && define.amd) {
3
+ define(["OctoPrintClient"], factory);
4
+ } else {
5
+ factory(global.OctoPrintClient);
6
+ }
7
+ })(this, function (OctoPrintClient) {
8
+ var OctoPrintWrappedClient = function (base) {
9
+ this.base = base;
10
+
11
+ this.baseUrl = this.base.getBlueprintUrl("wrapped");
12
+ this.apiUrl = this.base.getSimpleApiUrl("wrapped");
13
+ };
14
+
15
+ OctoPrintWrappedClient.prototype.get = function (opts) {
16
+ return this.base.get(this.apiUrl, opts);
17
+ };
18
+
19
+ OctoPrintWrappedClient.prototype.getYearSvgUrl = function (year, opts) {
20
+ return this.baseUrl + year + ".svg";
21
+ };
22
+
23
+ // register plugin component
24
+ OctoPrintClient.registerPluginComponent("wrapped", OctoPrintWrappedClient);
25
+
26
+ return OctoPrintWrappedClient;
27
+ });
@@ -0,0 +1,36 @@
1
+ ko.bindingHandlers["src.svgtopng"] = {
2
+ init: (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) => {
3
+ const value = valueAccessor();
4
+ const valueUnwrapped = ko.unwrap(value);
5
+ if (!valueUnwrapped) return;
6
+
7
+ const $element = $(element);
8
+
9
+ $.get(valueUnwrapped).done((data, textStatus, xhr) => {
10
+ const svgString = xhr.responseText;
11
+
12
+ const svg = new Blob([svgString], {
13
+ type: "image/svg+xml;charset=utf-8"
14
+ });
15
+
16
+ const url = URL.createObjectURL(svg);
17
+
18
+ const img = new Image();
19
+ img.onload = () => {
20
+ URL.revokeObjectURL(url);
21
+
22
+ const canvas = document.createElement("canvas");
23
+ canvas.width = img.width;
24
+ canvas.height = img.height;
25
+
26
+ const ctx = canvas.getContext("2d");
27
+ ctx.drawImage(img, 0, 0);
28
+
29
+ const png = canvas.toDataURL("image/png");
30
+ $element.attr("src", png);
31
+ };
32
+ img.src = url;
33
+ });
34
+ }
35
+ };
36
+ ko.bindingHandlers["src.svgtopng"].update = ko.bindingHandlers["src.svgtopng"].init;
@@ -0,0 +1,178 @@
1
+ /*
2
+ * View model for OctoPrint-Wrapped
3
+ *
4
+ * Author: Gina Häußge
5
+ * License: AGPL-3.0-or-later
6
+ */
7
+ $(function () {
8
+ const FLAKES = 50;
9
+ const MIN_DURATION = 5;
10
+ const MAX_DURATION = 15;
11
+
12
+ const MONTH_DECEMBER = 11;
13
+ const MONTH_JANUARY = 0;
14
+
15
+ const SNOWFALL_LOCAL_STORAGE_KEY = "plugin.wrapped.snowfall";
16
+ const snowfallToLocalStorage = (value) => {
17
+ saveToLocalStorage(SNOWFALL_LOCAL_STORAGE_KEY, {
18
+ enabled: value
19
+ });
20
+ };
21
+
22
+ const snowfallFromLocalStorage = () => {
23
+ const data = loadFromLocalStorage(SNOWFALL_LOCAL_STORAGE_KEY);
24
+ if (data["enabled"] !== undefined) return !!data["enabled"];
25
+ return false;
26
+ };
27
+
28
+ function WrappedViewModel(parameters) {
29
+ const self = this;
30
+
31
+ self.loginState = parameters[0];
32
+ self.access = parameters[1];
33
+ self.aboutVM = parameters[2];
34
+
35
+ self.availableYears = ko.observableArray([]);
36
+
37
+ self.snowfallEnabled = false;
38
+ self.snowfallContainer = undefined;
39
+
40
+ self.currentWrapped = ko.pureComputed(() => {
41
+ if (!self.withinWrappedSeason()) return false;
42
+
43
+ const now = new Date();
44
+ const year =
45
+ now.getMonth() == MONTH_DECEMBER
46
+ ? now.getFullYear() // still in December
47
+ : now.getFullYear() - 1; // already January
48
+ const years = self.availableYears();
49
+
50
+ if (years.indexOf(year) !== -1 && self.withinWrappedSeason()) {
51
+ return year;
52
+ } else {
53
+ return false;
54
+ }
55
+ });
56
+
57
+ self.currentWrappedAvailable = ko.pureComputed(() => {
58
+ return self.currentWrapped() !== false;
59
+ });
60
+
61
+ self.currentSvgUrl = ko.pureComputed(() => {
62
+ const year = self.currentWrapped();
63
+ if (!year) return false;
64
+
65
+ return OctoPrint.plugins.wrapped.getYearSvgUrl(year);
66
+ });
67
+
68
+ self.withinWrappedSeason = ko.pureComputed(() => {
69
+ // wrapped Season = Dec 1st until January 10th
70
+ const now = new Date();
71
+ return (
72
+ now.getMonth() == MONTH_DECEMBER ||
73
+ (now.getMonth() == MONTH_JANUARY && now.getDate() < 10)
74
+ );
75
+ });
76
+
77
+ self.requestData = () => {
78
+ if (
79
+ !self.loginState.hasPermission(
80
+ self.access.permissions.PLUGIN_ACHIEVEMENTS_VIEW
81
+ )
82
+ ) {
83
+ return;
84
+ }
85
+ OctoPrint.plugins.wrapped.get().done(self.fromResponse);
86
+ };
87
+
88
+ self.fromResponse = (response) => {
89
+ self.availableYears(response.years);
90
+ };
91
+
92
+ self.showWrapped = () => {
93
+ self.aboutVM.show("about_plugin_wrapped");
94
+ return false;
95
+ };
96
+
97
+ self.toggleSnowfall = () => {
98
+ self.snowfallEnabled = !self.snowfallEnabled;
99
+ snowfallToLocalStorage(self.snowfallEnabled);
100
+ self.updateSnowfall();
101
+ };
102
+
103
+ self.updateSnowfall = () => {
104
+ let container = document.getElementById("snow");
105
+
106
+ const body = document.getElementsByTagName("body")[0];
107
+ const head = document.getElementsByTagName("head")[0];
108
+
109
+ if (!self.snowfallEnabled) {
110
+ if (typeof showSnow !== "undefined") showSnow(false);
111
+ } else if (self.snowfallEnabled) {
112
+ if (!self.withinWrappedSeason()) return;
113
+
114
+ if (!container) {
115
+ container = document.createElement("div");
116
+ container.id = "snow";
117
+ container.dataset.count = FLAKES;
118
+ container.dataset.durmin = MIN_DURATION;
119
+ container.dataset.durmax = MAX_DURATION;
120
+ body.insertBefore(container, body.firstChild);
121
+
122
+ const styleSnow = document.createElement("link");
123
+ styleSnow.href =
124
+ BASEURL + "plugin/wrapped/static/pure-snow/pure-snow.css";
125
+ styleSnow.rel = "stylesheet";
126
+ head.appendChild(styleSnow);
127
+
128
+ const scriptSnow = document.createElement("script");
129
+ scriptSnow.src =
130
+ BASEURL + "plugin/wrapped/static/pure-snow/pure-snow.js";
131
+ scriptSnow.defer = true;
132
+ scriptSnow.onload = () => {
133
+ setTimeout(() => {
134
+ if (
135
+ typeof createSnow === "undefined" ||
136
+ typeof showSnow === "undefined"
137
+ )
138
+ return;
139
+ createSnow();
140
+ showSnow(true);
141
+ }, 500);
142
+ };
143
+ head.appendChild(scriptSnow);
144
+ } else {
145
+ if (typeof showSnow !== "undefined") showSnow(true);
146
+ }
147
+ }
148
+ };
149
+
150
+ self.onUserPermissionsChanged =
151
+ self.onUserLoggedIn =
152
+ self.onUserLoggedOut =
153
+ (user) => {
154
+ if (
155
+ self.loginState.hasPermission(
156
+ self.access.permissions.PLUGIN_ACHIEVEMENTS_VIEW
157
+ )
158
+ ) {
159
+ self.requestData();
160
+ }
161
+ };
162
+
163
+ self.onStartup = () => {
164
+ self.snowfallEnabled = snowfallFromLocalStorage();
165
+ self.updateSnowfall();
166
+ };
167
+ }
168
+
169
+ OCTOPRINT_VIEWMODELS.push({
170
+ construct: WrappedViewModel,
171
+ dependencies: ["loginStateViewModel", "accessViewModel", "aboutViewModel"],
172
+ elements: [
173
+ "#about_plugin_wrapped",
174
+ "#navbar_plugin_wrapped",
175
+ "#navbar_plugin_wrapped_2"
176
+ ]
177
+ });
178
+ });
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2020-present, hyperstown (github.com/hyperstown/)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,10 @@
1
+ .snowflake {
2
+ z-index: 32768;
3
+ position: absolute;
4
+ width: 10px;
5
+ height: 10px;
6
+ background: linear-gradient(white, white);
7
+ /* Workaround for Chromium's selective color inversion */
8
+ border-radius: 50%;
9
+ filter: drop-shadow(0 0 10px black);
10
+ }
@@ -0,0 +1,146 @@
1
+ let snowflakesCount = 200; // Snowflake count, can be overwritten by attrs
2
+ let minDuration = 10; // can be overwritten by attrs
3
+ let maxDuration = 30; // can be overwritten by attrs
4
+ let baseCSS = ``;
5
+
6
+ // set global attributes
7
+ if (typeof SNOWFLAKES_COUNT !== "undefined") {
8
+ snowflakesCount = SNOWFLAKES_COUNT;
9
+ }
10
+ if (typeof SNOWFLAKES_MIN_DURATION !== "undefined") {
11
+ minDuration = SNOWFLAKES_MIN_DURATION;
12
+ }
13
+ if (typeof SNOWFLAKES_MAX_DURATION !== "undefined") {
14
+ maxDuration = SNOWFLAKES_MAX_DURATION;
15
+ }
16
+ if (typeof BASE_CSS !== "undefined") {
17
+ baseCSS = BASE_CSS;
18
+ }
19
+
20
+ let bodyHeightPx = null;
21
+ let pageHeightVh = null;
22
+
23
+ function setHeightVariables() {
24
+ bodyHeightPx = document.body.offsetHeight;
25
+ pageHeightVh = Math.max((100 * bodyHeightPx) / window.innerHeight, 100.0);
26
+ }
27
+
28
+ // get params set in snow div
29
+ function getSnowAttributes() {
30
+ const snowWrapper = document.getElementById("snow");
31
+ snowflakesCount = Number(snowWrapper?.dataset?.count || snowflakesCount);
32
+ minDuration = Number(snowWrapper?.dataset?.durmin || minDuration);
33
+ maxDuration = Number(snowWrapper?.dataset?.durmax || maxDuration);
34
+ }
35
+
36
+ // This function allows you to turn on and off the snow
37
+ function showSnow(value) {
38
+ if (value) {
39
+ document.getElementById("snow").style.display = "block";
40
+ } else {
41
+ document.getElementById("snow").style.display = "none";
42
+ }
43
+ }
44
+
45
+ // Creating snowflakes
46
+ function generateSnow(snowDensity = 200) {
47
+ snowDensity -= 1;
48
+ const snowWrapper = document.getElementById("snow");
49
+ snowWrapper.innerHTML = "";
50
+ for (let i = 0; i < snowDensity; i++) {
51
+ let board = document.createElement("div");
52
+ board.className = "snowflake";
53
+ snowWrapper.appendChild(board);
54
+ }
55
+ }
56
+
57
+ function getOrCreateCSSElement() {
58
+ let cssElement = document.getElementById("psjs-css");
59
+ if (cssElement) return cssElement;
60
+
61
+ cssElement = document.createElement("style");
62
+ cssElement.id = "psjs-css";
63
+ document.head.appendChild(cssElement);
64
+ return cssElement;
65
+ }
66
+
67
+ // Append style for each snowflake to the head
68
+ function addCSS(rule) {
69
+ const cssElement = getOrCreateCSSElement();
70
+ cssElement.innerHTML = rule; // safe to use innerHTML
71
+ document.head.appendChild(cssElement);
72
+ }
73
+
74
+ // Math
75
+ function randomInt(value = 100) {
76
+ return Math.floor(Math.random() * value) + 1;
77
+ }
78
+
79
+ function randomIntRange(min, max) {
80
+ min = Math.ceil(min);
81
+ max = Math.floor(max);
82
+ return Math.floor(Math.random() * (max - min + 1)) + min;
83
+ }
84
+
85
+ function getRandomArbitrary(min, max) {
86
+ return Math.random() * (max - min) + min;
87
+ }
88
+
89
+ // Create style for snowflake
90
+ function generateSnowCSS(snowDensity = 200) {
91
+ let snowflakeName = "snowflake";
92
+ let rule = baseCSS;
93
+
94
+ for (let i = 1; i < snowDensity; i++) {
95
+ let randomX = Math.random() * 100; // vw
96
+ let randomOffset = Math.random() * 10; // vw;
97
+ let randomXEnd = randomX + randomOffset;
98
+ let randomXEndYoyo = randomX + randomOffset / 2;
99
+ let randomYoyoTime = getRandomArbitrary(0.3, 0.8);
100
+ let randomYoyoY = randomYoyoTime * pageHeightVh; // vh
101
+ let randomScale = Math.random();
102
+ let fallDuration = Math.min(
103
+ randomIntRange(minDuration, (pageHeightVh / 10) * 3),
104
+ maxDuration
105
+ ); // s
106
+ let fallDelay = randomInt((pageHeightVh / 10) * 3) * -1; // s
107
+ let opacity = Math.random();
108
+
109
+ rule += `
110
+ .${snowflakeName}:nth-child(${i}) {
111
+ opacity: ${opacity};
112
+ transform: translate(${randomX}vw, -10px) scale(${randomScale});
113
+ animation: fall-${i} ${fallDuration}s ${fallDelay}s linear infinite;
114
+ }
115
+ @keyframes fall-${i} {
116
+ ${randomYoyoTime * 100}% {
117
+ transform: translate(${randomXEnd}vw, ${randomYoyoY}vh) scale(${randomScale});
118
+ }
119
+ to {
120
+ transform: translate(${randomXEndYoyo}vw, ${pageHeightVh}vh) scale(${randomScale});
121
+ }
122
+ }
123
+ `;
124
+ }
125
+ addCSS(rule);
126
+ }
127
+
128
+ // Load the rules and execute after the DOM loads
129
+ function createSnow() {
130
+ setHeightVariables();
131
+ getSnowAttributes();
132
+ generateSnowCSS(snowflakesCount);
133
+ generateSnow(snowflakesCount);
134
+ }
135
+
136
+ window.addEventListener("resize", createSnow);
137
+
138
+ // export createSnow function if using node or CommonJS environment
139
+ if (typeof module !== "undefined") {
140
+ module.exports = {
141
+ createSnow,
142
+ showSnow
143
+ };
144
+ } else {
145
+ window.onload = createSnow;
146
+ }