composeui 0.1.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.
- composeui/__init__.py +11 -0
- composeui/core/__init__.py +0 -0
- composeui/core/application.py +368 -0
- composeui/core/chartcolors.py +23 -0
- composeui/core/chartimage.py +336 -0
- composeui/core/dataframesource.py +307 -0
- composeui/core/jobs.py +426 -0
- composeui/core/model.py +66 -0
- composeui/core/registry.py +66 -0
- composeui/core/reload.py +476 -0
- composeui/core/renderer.py +193 -0
- composeui/core/signal.py +358 -0
- composeui/core/sqlitemodel.py +1183 -0
- composeui/core/study.py +121 -0
- composeui/core/translation.py +221 -0
- composeui/core/translationcheck.py +254 -0
- composeui/core/view.py +5508 -0
- composeui/images/composeui.svg +15 -0
- composeui/locales/fr.json +73 -0
- composeui/mypy.py +46 -0
- composeui/py.typed +0 -0
- composeui/qtcore/__init__.py +0 -0
- composeui/qtcore/maprenderer.py +115 -0
- composeui/qtcore/qtapplication.py +222 -0
- composeui/qtcore/qtrenderer.py +2862 -0
- composeui/qtcore/widgets/__init__.py +0 -0
- composeui/qtcore/widgets/map.qml +123 -0
- composeui/qtcore/widgets/qchart.py +393 -0
- composeui/qtcore/widgets/qdatefield.py +101 -0
- composeui/qtcore/widgets/qexpander.py +60 -0
- composeui/qtcore/widgets/qfilepathfield.py +34 -0
- composeui/qtcore/widgets/qfiltertable.py +394 -0
- composeui/qtcore/widgets/qformdialog.py +64 -0
- composeui/qtcore/widgets/qformlabel.py +53 -0
- composeui/qtcore/widgets/qmap.py +68 -0
- composeui/qtcore/widgets/qmarkicon.py +37 -0
- composeui/qtcore/widgets/qmetric.py +44 -0
- composeui/qtcore/widgets/qplaintext.py +29 -0
- composeui/qtcore/widgets/qradiogroup.py +57 -0
- composeui/qtcore/widgets/qsidebar.py +116 -0
- composeui/qtcore/widgets/qsourcetable.py +216 -0
- composeui/qtcore/widgets/qsplitter.py +69 -0
- composeui/qtcore/widgets/qtexttable.py +703 -0
- composeui/qtcore/widgets/qvaluecombobox.py +41 -0
- composeui/qtcore/widgets/qvalueslider.py +64 -0
- composeui/qtcore/widgets/qwindowrelease.py +32 -0
- composeui/textualcore/__init__.py +0 -0
- composeui/textualcore/textualapplication.py +121 -0
- composeui/textualcore/textualrenderer.py +2893 -0
- composeui/textualcore/widgets/__init__.py +0 -0
- composeui/textualcore/widgets/composebutton.py +17 -0
- composeui/textualcore/widgets/composechart.py +212 -0
- composeui/textualcore/widgets/composecheckbox.py +35 -0
- composeui/textualcore/widgets/composecollapsible.py +41 -0
- composeui/textualcore/widgets/composecombobox.py +54 -0
- composeui/textualcore/widgets/composedateinput.py +325 -0
- composeui/textualcore/widgets/composedialog.py +90 -0
- composeui/textualcore/widgets/composefilepathinput.py +85 -0
- composeui/textualcore/widgets/composefiltertable.py +367 -0
- composeui/textualcore/widgets/composeformdialog.py +86 -0
- composeui/textualcore/widgets/composeinput.py +50 -0
- composeui/textualcore/widgets/composelist.py +20 -0
- composeui/textualcore/widgets/composemarkdown.py +17 -0
- composeui/textualcore/widgets/composemetric.py +60 -0
- composeui/textualcore/widgets/composeradioset.py +54 -0
- composeui/textualcore/widgets/composesidebar.py +111 -0
- composeui/textualcore/widgets/composeslider.py +144 -0
- composeui/textualcore/widgets/composesourcetable.py +288 -0
- composeui/textualcore/widgets/composesplitter.py +211 -0
- composeui/textualcore/widgets/composetabbedcontent.py +35 -0
- composeui/textualcore/widgets/composetable.py +476 -0
- composeui/textualcore/widgets/composetoolbar.py +62 -0
- composeui/textualcore/widgets/composetree.py +139 -0
- composeui/webcore/__init__.py +0 -0
- composeui/webcore/headers.py +57 -0
- composeui/webcore/icons.py +58 -0
- composeui/webcore/limits.py +55 -0
- composeui/webcore/origin.py +63 -0
- composeui/webcore/page.py +146 -0
- composeui/webcore/routes.py +303 -0
- composeui/webcore/session.py +436 -0
- composeui/webcore/static/app.css +2 -0
- composeui/webcore/static/cally.js +1312 -0
- composeui/webcore/static/daisyui.LICENSE +21 -0
- composeui/webcore/static/deckgl.LICENSE +19 -0
- composeui/webcore/static/deckgl.min.js +6423 -0
- composeui/webcore/static/htmx.min.js +1 -0
- composeui/webcore/static/page.js +1175 -0
- composeui/webcore/static/uPlot.LICENSE +21 -0
- composeui/webcore/static/uPlot.iife.min.js +2 -0
- composeui/webcore/webapplication.py +495 -0
- composeui/webcore/webrenderer.py +5113 -0
- composeui-0.1.0.dist-info/METADATA +222 -0
- composeui-0.1.0.dist-info/RECORD +97 -0
- composeui-0.1.0.dist-info/WHEEL +5 -0
- composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
- composeui-0.1.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import QtQuick
|
|
2
|
+
import QtLocation
|
|
3
|
+
import QtPositioning
|
|
4
|
+
|
|
5
|
+
Item {
|
|
6
|
+
id: root
|
|
7
|
+
property var points: []
|
|
8
|
+
property bool tiles: true
|
|
9
|
+
property real latitude: 0
|
|
10
|
+
property real longitude: 0
|
|
11
|
+
property alias zoom: map.zoomLevel
|
|
12
|
+
focus: true
|
|
13
|
+
signal viewportMoved(real latitude, real longitude, real zoom)
|
|
14
|
+
signal pointClicked(real latitude, real longitude)
|
|
15
|
+
|
|
16
|
+
function moved() {
|
|
17
|
+
// Copy both numbers before changing the properties that bind map.center.
|
|
18
|
+
const movedLatitude = map.center.latitude
|
|
19
|
+
const movedLongitude = map.center.longitude
|
|
20
|
+
latitude = Math.max(-85.05112878, Math.min(85.05112878, movedLatitude))
|
|
21
|
+
longitude = movedLongitude
|
|
22
|
+
viewportMoved(latitude, longitude, map.zoomLevel)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
Keys.onPressed: (event) => {
|
|
26
|
+
if (event.key === Qt.Key_Plus || event.key === Qt.Key_Equal) zoom = Math.min(19, zoom + 1)
|
|
27
|
+
else if (event.key === Qt.Key_Minus) zoom = Math.max(0, zoom - 1)
|
|
28
|
+
else if (event.key === Qt.Key_Left) map.pan(-40, 0)
|
|
29
|
+
else if (event.key === Qt.Key_Right) map.pan(40, 0)
|
|
30
|
+
else if (event.key === Qt.Key_Up) map.pan(0, -40)
|
|
31
|
+
else if (event.key === Qt.Key_Down) map.pan(0, 40)
|
|
32
|
+
else return
|
|
33
|
+
event.accepted = true
|
|
34
|
+
moved()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
Map {
|
|
38
|
+
id: map
|
|
39
|
+
center: QtPositioning.coordinate(root.latitude, root.longitude)
|
|
40
|
+
anchors.fill: parent
|
|
41
|
+
visible: root.tiles
|
|
42
|
+
copyrightsVisible: root.tiles
|
|
43
|
+
color: "#e5e7eb"
|
|
44
|
+
plugin: tilePlugin
|
|
45
|
+
onSupportedMapTypesChanged: {
|
|
46
|
+
// The custom host is the last map type; never select a provider repository URL.
|
|
47
|
+
if (root.tiles && supportedMapTypes.length)
|
|
48
|
+
activeMapType = supportedMapTypes[supportedMapTypes.length - 1]
|
|
49
|
+
}
|
|
50
|
+
onCenterChanged: overlay.requestPaint()
|
|
51
|
+
onZoomLevelChanged: overlay.requestPaint()
|
|
52
|
+
onMapReadyChanged: overlay.requestPaint()
|
|
53
|
+
}
|
|
54
|
+
Plugin {
|
|
55
|
+
id: tilePlugin
|
|
56
|
+
name: root.tiles ? "osm" : "itemsoverlay"
|
|
57
|
+
PluginParameter { name: "osm.useragent"; value: mapUserAgent }
|
|
58
|
+
PluginParameter { name: "osm.mapping.providersrepository.disabled"; value: true }
|
|
59
|
+
PluginParameter {
|
|
60
|
+
name: "osm.mapping.custom.host"
|
|
61
|
+
value: "https://tile.openstreetmap.org/"
|
|
62
|
+
}
|
|
63
|
+
PluginParameter {
|
|
64
|
+
name: "osm.mapping.custom.mapcopyright"
|
|
65
|
+
value: "© OpenStreetMap contributors"
|
|
66
|
+
}
|
|
67
|
+
PluginParameter { name: "osm.mapping.prefetching_style"; value: "NoPrefetching" }
|
|
68
|
+
}
|
|
69
|
+
Item {
|
|
70
|
+
anchors.fill: parent
|
|
71
|
+
DragHandler {
|
|
72
|
+
target: null
|
|
73
|
+
onTranslationChanged: (delta) => {
|
|
74
|
+
map.pan(-delta.x, -delta.y)
|
|
75
|
+
root.moved()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
WheelHandler {
|
|
79
|
+
target: null
|
|
80
|
+
onWheel: (event) => {
|
|
81
|
+
map.zoomLevel = Math.max(0, Math.min(19, map.zoomLevel + event.angleDelta.y / 120))
|
|
82
|
+
root.moved()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
TapHandler {
|
|
86
|
+
onTapped: (eventPoint) => {
|
|
87
|
+
const position = map.toCoordinate(eventPoint.position, false)
|
|
88
|
+
root.pointClicked(position.latitude, position.longitude)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
Canvas {
|
|
93
|
+
id: overlay
|
|
94
|
+
anchors.fill: parent
|
|
95
|
+
onPaint: {
|
|
96
|
+
const ctx = getContext("2d")
|
|
97
|
+
ctx.clearRect(0, 0, width, height)
|
|
98
|
+
ctx.fillStyle = "#2563eb"
|
|
99
|
+
ctx.globalAlpha = 0.55
|
|
100
|
+
ctx.beginPath()
|
|
101
|
+
const world = 256 * Math.pow(2, root.zoom)
|
|
102
|
+
function mercator(latitude) {
|
|
103
|
+
const sine = Math.sin(latitude * Math.PI / 180)
|
|
104
|
+
return 0.5 - Math.log((1 + sine) / (1 - sine)) / (4 * Math.PI)
|
|
105
|
+
}
|
|
106
|
+
const centerY = mercator(root.latitude)
|
|
107
|
+
for (let i = 0; i < root.points.length; ++i) {
|
|
108
|
+
const p = root.points[i]
|
|
109
|
+
let dx = (p[1] - root.longitude) / 360
|
|
110
|
+
dx -= Math.round(dx)
|
|
111
|
+
const pixel = { x: width / 2 + dx * world,
|
|
112
|
+
y: height / 2 + (mercator(p[0]) - centerY) * world }
|
|
113
|
+
if (pixel.x < -3 || pixel.x > width + 3 || pixel.y < -3 || pixel.y > height + 3) continue
|
|
114
|
+
ctx.moveTo(pixel.x + 3, pixel.y)
|
|
115
|
+
ctx.arc(pixel.x, pixel.y, 3, 0, 2 * Math.PI)
|
|
116
|
+
}
|
|
117
|
+
ctx.fill()
|
|
118
|
+
}
|
|
119
|
+
onWidthChanged: requestPaint()
|
|
120
|
+
onHeightChanged: requestPaint()
|
|
121
|
+
}
|
|
122
|
+
onPointsChanged: overlay.requestPaint()
|
|
123
|
+
}
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""A chart widget over pyqtgraph: fast with long series, zoomed and panned with the mouse."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pyqtgraph as pg
|
|
10
|
+
from qtpy.QtCore import QEvent, QPointF, QSize, Qt
|
|
11
|
+
from qtpy.QtCore import Signal as QtSignal
|
|
12
|
+
from qtpy.QtGui import QAction, QColor, QFont, QPalette
|
|
13
|
+
|
|
14
|
+
from composeui.core import chartimage
|
|
15
|
+
from composeui.core.chartcolors import is_dashed, series_color
|
|
16
|
+
from composeui.core.view import ChartKind, ChartView, LineStyle
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from qtpy.QtWidgets import QWidget
|
|
20
|
+
|
|
21
|
+
from composeui.core.view import ReferenceLine, Series
|
|
22
|
+
|
|
23
|
+
#: the hover values' rate, per second: a mouse sends far more moves than that
|
|
24
|
+
_HOVER_RATE = 30
|
|
25
|
+
#: the lightness (of 255) under which a background is dark
|
|
26
|
+
_DARK_LIGHTNESS = 128
|
|
27
|
+
#: the size a chart asks of its layout, in pixels: pyqtgraph's own, before it is shown
|
|
28
|
+
_SIZE_HINT = (600, 480)
|
|
29
|
+
#: a scatter's dots, in pixels
|
|
30
|
+
_DOT_SIZE = 8
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
#: a reference line's pen style, by its style
|
|
34
|
+
_LINE_STYLES = {
|
|
35
|
+
LineStyle.SOLID: Qt.PenStyle.SolidLine,
|
|
36
|
+
LineStyle.DASHED: Qt.PenStyle.DashLine,
|
|
37
|
+
LineStyle.DOTTED: Qt.PenStyle.DotLine,
|
|
38
|
+
LineStyle.DASH_DOT: Qt.PenStyle.DashDotLine,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _format(value: float, unit: str) -> str:
|
|
43
|
+
# its trailing zeros kept: as many digits wherever the cursor is, as on the web
|
|
44
|
+
return f"{value:#.4g} {unit}".rstrip()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _tabular_font() -> QFont:
|
|
48
|
+
"""Get the application's font with digits of one width, where Qt has them (6.7)."""
|
|
49
|
+
font = QFont()
|
|
50
|
+
if hasattr(font, "setFeature") and hasattr(QFont, "Tag"): # pragma: no branch
|
|
51
|
+
font.setFeature(QFont.Tag("tnum"), 1)
|
|
52
|
+
return font
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class QChart(pg.PlotWidget): # type: ignore[misc]
|
|
56
|
+
"""A chart of series; hovering it shows each series' point nearest the cursor's x.
|
|
57
|
+
|
|
58
|
+
Its colors follow the application's palette, changing with it (a dark scheme).
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
#: the user asked for the chart's image, from its context menu's Save Image
|
|
62
|
+
save_image_requested = QtSignal()
|
|
63
|
+
#: the user clicked the plot at a point, as the axes' values (not their logs)
|
|
64
|
+
point_clicked = QtSignal(float, float)
|
|
65
|
+
#: whether `__init__` is done: pyqtgraph's own changes the palette before it is
|
|
66
|
+
_is_built = False
|
|
67
|
+
|
|
68
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
69
|
+
super().__init__(parent)
|
|
70
|
+
self._series: list[Series] = []
|
|
71
|
+
self._kind = ChartKind.LINE
|
|
72
|
+
self._categories: list[str] = []
|
|
73
|
+
self._x_log = False
|
|
74
|
+
self._y_log = False
|
|
75
|
+
self._bars: list[pg.BarGraphItem] = []
|
|
76
|
+
self._units = ("", "")
|
|
77
|
+
self._items: list[pg.PlotDataItem] = []
|
|
78
|
+
self._reference_lines: list[ReferenceLine] = []
|
|
79
|
+
self._line_items: list[pg.InfiniteLine] = []
|
|
80
|
+
plot = self.getPlotItem()
|
|
81
|
+
self._legend = plot.addLegend()
|
|
82
|
+
# pyqtgraph thins and clips what it draws itself: a million points stay fluid
|
|
83
|
+
plot.setDownsampling(auto=True, mode="peak")
|
|
84
|
+
plot.setClipToView(True)
|
|
85
|
+
plot.showGrid(x=True, y=True, alpha=0.15)
|
|
86
|
+
self._crosshair = pg.InfiniteLine(angle=90, movable=False)
|
|
87
|
+
self._crosshair.setVisible(False)
|
|
88
|
+
self._hover = pg.TextItem(anchor=(0, 1))
|
|
89
|
+
# its values change as the cursor moves, without changing their width
|
|
90
|
+
self._hover.setFont(_tabular_font())
|
|
91
|
+
self._hover.setVisible(False)
|
|
92
|
+
for item in (self._crosshair, self._hover):
|
|
93
|
+
plot.addItem(item, ignoreBounds=True)
|
|
94
|
+
self.scene().sigMouseClicked.connect(self._on_mouse_clicked)
|
|
95
|
+
self._mouse_proxy = pg.SignalProxy(
|
|
96
|
+
self.scene().sigMouseMoved, rateLimit=_HOVER_RATE, slot=self._on_mouse_moved
|
|
97
|
+
)
|
|
98
|
+
#: beside pyqtgraph's own Export, which stays for CSV; only with matplotlib, which
|
|
99
|
+
#: draws the image
|
|
100
|
+
self.save_image_action = QAction(self)
|
|
101
|
+
self.save_image_action.triggered.connect(self.save_image_requested.emit)
|
|
102
|
+
if chartimage.is_available():
|
|
103
|
+
plot.getViewBox().menu.addAction(self.save_image_action)
|
|
104
|
+
self._is_built = True
|
|
105
|
+
self._apply_palette()
|
|
106
|
+
|
|
107
|
+
def set_title(self, title: str) -> None:
|
|
108
|
+
self.getPlotItem().setTitle(title or None)
|
|
109
|
+
|
|
110
|
+
def set_axis(self, axis: str, title: str, unit: str) -> None:
|
|
111
|
+
"""Label the `axis` ("bottom" or "left"); pyqtgraph scales the unit, e.g. into ms."""
|
|
112
|
+
self.getPlotItem().setLabel(axis, title or None, units=unit or None)
|
|
113
|
+
x_unit, y_unit = self._units
|
|
114
|
+
self._units = (unit, y_unit) if axis == "bottom" else (x_unit, unit)
|
|
115
|
+
|
|
116
|
+
def set_kind(self, kind: ChartKind) -> None:
|
|
117
|
+
"""Draw the series as `kind`, unless they say otherwise (`Series.kind`)."""
|
|
118
|
+
self._kind = kind
|
|
119
|
+
self._draw()
|
|
120
|
+
|
|
121
|
+
def set_categories(self, categories: list[str]) -> None:
|
|
122
|
+
"""Name a bar chart's categories, under their groups of bars."""
|
|
123
|
+
self._categories = categories
|
|
124
|
+
self._draw()
|
|
125
|
+
|
|
126
|
+
def set_log(self, *, x_log: bool, y_log: bool) -> None:
|
|
127
|
+
"""Make the axes log axes or linear ones; a bar chart's x stays its categories."""
|
|
128
|
+
self._x_log, self._y_log = x_log, y_log
|
|
129
|
+
self._draw()
|
|
130
|
+
|
|
131
|
+
def set_series(self, series: list[Series]) -> None:
|
|
132
|
+
"""Show `series`, reusing the items drawn: the zoom stays where the user put it."""
|
|
133
|
+
self._series = series
|
|
134
|
+
self._draw()
|
|
135
|
+
self._hover.setVisible(False)
|
|
136
|
+
self._crosshair.setVisible(False)
|
|
137
|
+
|
|
138
|
+
def set_reference_lines(self, lines: list[ReferenceLine]) -> None:
|
|
139
|
+
"""Draw lines at values, each in its style, labeled beside itself."""
|
|
140
|
+
self._reference_lines = lines
|
|
141
|
+
self._draw()
|
|
142
|
+
|
|
143
|
+
def _draw(self) -> None:
|
|
144
|
+
"""Draw the series as curves, or as a bar chart's bars, and their legend."""
|
|
145
|
+
plot = self.getPlotItem()
|
|
146
|
+
is_bar = self._kind is ChartKind.BAR
|
|
147
|
+
curve_count, bar_count = (0, len(self._series)) if is_bar else (len(self._series), 0)
|
|
148
|
+
for items, count, make in (
|
|
149
|
+
(self._items, curve_count, lambda: plot.plot(connect="finite")),
|
|
150
|
+
(self._bars, bar_count, self._make_bars),
|
|
151
|
+
):
|
|
152
|
+
while len(items) > count:
|
|
153
|
+
plot.removeItem(items.pop())
|
|
154
|
+
while len(items) < count:
|
|
155
|
+
items.append(make())
|
|
156
|
+
x_log = self._x_log and not is_bar
|
|
157
|
+
for item, curve in zip(self._items, self._series):
|
|
158
|
+
x, y = ChartView.drawn_points(curve, x_log=x_log, y_log=self._y_log)
|
|
159
|
+
if self._is_step(curve):
|
|
160
|
+
x, y = ChartView.step_points(x, y)
|
|
161
|
+
item.setData(np.asarray(x, dtype=float), np.asarray(y, dtype=float))
|
|
162
|
+
item.opts["name"] = curve.name
|
|
163
|
+
# the curves draw their log themselves; the bars are given theirs (see `_bar_opts`)
|
|
164
|
+
plot.setLogMode(x=x_log, y=self._y_log)
|
|
165
|
+
ticks = [list(enumerate(self._categories))] if is_bar else None
|
|
166
|
+
plot.getAxis("bottom").setTicks(ticks)
|
|
167
|
+
self._draw_reference_lines(x_log=x_log)
|
|
168
|
+
self._apply_styles()
|
|
169
|
+
|
|
170
|
+
def _draw_reference_lines(self, *, x_log: bool) -> None:
|
|
171
|
+
"""Draw the reference lines afresh: a log axis takes their logs, as pyqtgraph doesn't."""
|
|
172
|
+
plot = self.getPlotItem()
|
|
173
|
+
for item in self._line_items:
|
|
174
|
+
plot.removeItem(item)
|
|
175
|
+
self._line_items = []
|
|
176
|
+
lines = ChartView.drawn_lines(self._reference_lines, x_log=x_log, y_log=self._y_log)
|
|
177
|
+
for line in lines:
|
|
178
|
+
is_log = x_log if line.vertical else self._y_log
|
|
179
|
+
item = pg.InfiniteLine(
|
|
180
|
+
pos=math.log10(line.value) if is_log else line.value,
|
|
181
|
+
angle=90 if line.vertical else 0,
|
|
182
|
+
movable=False,
|
|
183
|
+
# the label is a format of the line's value: braces of its own are text
|
|
184
|
+
label=line.label.replace("{", "{{").replace("}", "}}"),
|
|
185
|
+
# a horizontal one's at its right end, over it; a vertical one's at its
|
|
186
|
+
# top, left of it (now, after the data)
|
|
187
|
+
labelOpts={
|
|
188
|
+
"position": 0.98,
|
|
189
|
+
"anchors": [(1, 0), (1, 0)] if line.vertical else [(1, 1), (1, 1)],
|
|
190
|
+
},
|
|
191
|
+
)
|
|
192
|
+
item.setZValue(1) # over the series
|
|
193
|
+
plot.addItem(item)
|
|
194
|
+
self._line_items.append(item)
|
|
195
|
+
|
|
196
|
+
def _make_bars(self) -> pg.BarGraphItem:
|
|
197
|
+
bars = pg.BarGraphItem(x0=[], height=[], width=1)
|
|
198
|
+
self.getPlotItem().addItem(bars)
|
|
199
|
+
return bars
|
|
200
|
+
|
|
201
|
+
def shown_range(self) -> tuple[tuple[float, float], tuple[float, float]]:
|
|
202
|
+
"""Get the x and y ranges the chart shows, e.g. zoomed into; not their logs."""
|
|
203
|
+
(x_min, x_max), (y_min, y_max) = self.getPlotItem().getViewBox().viewRange()
|
|
204
|
+
if self._x_log and self._kind is not ChartKind.BAR:
|
|
205
|
+
x_min, x_max = 10**x_min, 10**x_max
|
|
206
|
+
if self._y_log:
|
|
207
|
+
y_min, y_max = 10**y_min, 10**y_max
|
|
208
|
+
return (float(x_min), float(x_max)), (float(y_min), float(y_max))
|
|
209
|
+
|
|
210
|
+
def sizeHint(self) -> QSize: # noqa: N802
|
|
211
|
+
# not its scene's, which follows its own size: a height it was given once would stay
|
|
212
|
+
return QSize(*_SIZE_HINT)
|
|
213
|
+
|
|
214
|
+
def legend_names(self) -> list[str]:
|
|
215
|
+
return [label.text for _sample, label in self._legend.items]
|
|
216
|
+
|
|
217
|
+
def hover_text(self) -> str:
|
|
218
|
+
"""Get the values the hover shows, empty while the cursor is off the chart."""
|
|
219
|
+
return str(self._hover.toPlainText()) if self._hover.isVisible() else ""
|
|
220
|
+
|
|
221
|
+
def changeEvent(self, event: QEvent) -> None: # noqa: N802
|
|
222
|
+
super().changeEvent(event)
|
|
223
|
+
if event.type() == QEvent.Type.PaletteChange and self._is_built:
|
|
224
|
+
self._apply_palette()
|
|
225
|
+
|
|
226
|
+
def _is_dark(self) -> bool:
|
|
227
|
+
return bool(self.palette().color(QPalette.ColorRole.Window).lightness() < _DARK_LIGHTNESS)
|
|
228
|
+
|
|
229
|
+
def _apply_palette(self) -> None:
|
|
230
|
+
palette = self.palette()
|
|
231
|
+
background = palette.color(QPalette.ColorRole.Window)
|
|
232
|
+
text = palette.color(QPalette.ColorRole.WindowText)
|
|
233
|
+
self.setBackground(background)
|
|
234
|
+
plot = self.getPlotItem()
|
|
235
|
+
for name in ("bottom", "left"):
|
|
236
|
+
axis = plot.getAxis(name)
|
|
237
|
+
axis.setPen(text)
|
|
238
|
+
axis.setTextPen(text)
|
|
239
|
+
plot.titleLabel.setText(plot.titleLabel.text, color=text)
|
|
240
|
+
self._legend.setLabelTextColor(text)
|
|
241
|
+
# over the data, so its names stay readable over a curve or a bar
|
|
242
|
+
legend_background = QColor(background)
|
|
243
|
+
legend_background.setAlpha(200)
|
|
244
|
+
self._legend.setBrush(legend_background)
|
|
245
|
+
self._hover.setColor(text)
|
|
246
|
+
crosshair = QColor(text)
|
|
247
|
+
crosshair.setAlpha(120)
|
|
248
|
+
self._crosshair.setPen(pg.mkPen(crosshair))
|
|
249
|
+
self._apply_styles()
|
|
250
|
+
|
|
251
|
+
def _apply_styles(self) -> None:
|
|
252
|
+
is_dark = self._is_dark()
|
|
253
|
+
background = self.palette().color(QPalette.ColorRole.Window)
|
|
254
|
+
for index, (item, curve) in enumerate(zip(self._items, self._series)):
|
|
255
|
+
color = curve.color or series_color(index, is_dark=is_dark)
|
|
256
|
+
# filled down to 0, e.g. a shape
|
|
257
|
+
item.setFillLevel(0 if curve.fill else None)
|
|
258
|
+
item.setBrush(color if curve.fill else None)
|
|
259
|
+
if ChartView.kind_of(curve, self._kind) is ChartKind.SCATTER:
|
|
260
|
+
item.setPen(None)
|
|
261
|
+
# past the palette, around again as triangles
|
|
262
|
+
item.setSymbol("t" if is_dashed(index) else "o")
|
|
263
|
+
item.setSymbolSize(_DOT_SIZE)
|
|
264
|
+
item.setSymbolBrush(color)
|
|
265
|
+
# a ring of the background: overlapping dots stay apart
|
|
266
|
+
item.setSymbolPen(pg.mkPen(background, width=1))
|
|
267
|
+
else:
|
|
268
|
+
item.setSymbol(None)
|
|
269
|
+
style = {"dash": [6, 4]} if is_dashed(index) else {}
|
|
270
|
+
item.setPen(pg.mkPen(color, width=2, **style))
|
|
271
|
+
all_heights = [
|
|
272
|
+
np.asarray(ChartView.bar_heights(curve, len(self._categories), y_log=self._y_log))
|
|
273
|
+
for curve in self._series
|
|
274
|
+
]
|
|
275
|
+
drawn = np.concatenate([[], *all_heights])
|
|
276
|
+
drawn = drawn[np.isfinite(drawn)]
|
|
277
|
+
# on a log axis, the bars rise from the power of 10 under the lowest of them
|
|
278
|
+
base = math.floor(np.log10(drawn.min())) if self._y_log and len(drawn) > 0 else 0
|
|
279
|
+
for index, (bars, curve, heights) in enumerate(zip(self._bars, self._series, all_heights)):
|
|
280
|
+
categories = np.flatnonzero(np.isfinite(heights)) # a NaN: no bar
|
|
281
|
+
tops = np.log10(heights[categories]) if self._y_log else heights[categories]
|
|
282
|
+
offset, width = ChartView.bar_slot(index, len(self._series))
|
|
283
|
+
bars.setOpts(
|
|
284
|
+
x0=categories + offset - width / 2,
|
|
285
|
+
y0=base,
|
|
286
|
+
height=tops - base,
|
|
287
|
+
width=width,
|
|
288
|
+
brush=curve.color or series_color(index, is_dark=is_dark),
|
|
289
|
+
# a line of the background between neighbouring bars
|
|
290
|
+
pen=pg.mkPen(background, width=1),
|
|
291
|
+
name=curve.name,
|
|
292
|
+
)
|
|
293
|
+
text = self.palette().color(QPalette.ColorRole.WindowText)
|
|
294
|
+
lines = ChartView.drawn_lines(
|
|
295
|
+
self._reference_lines,
|
|
296
|
+
x_log=self._x_log and self._kind is not ChartKind.BAR,
|
|
297
|
+
y_log=self._y_log,
|
|
298
|
+
)
|
|
299
|
+
for item, line in zip(self._line_items, lines):
|
|
300
|
+
ink = QColor(line.color) if line.color is not None else text
|
|
301
|
+
item.setPen(pg.mkPen(ink, width=1.5, style=_LINE_STYLES[line.style]))
|
|
302
|
+
item.label.setColor(ink)
|
|
303
|
+
# the legend's samples draw the styles their items had when added
|
|
304
|
+
self._legend.clear()
|
|
305
|
+
for item, curve in zip(self._items or self._bars, self._series):
|
|
306
|
+
self._legend.addItem(item, curve.name)
|
|
307
|
+
|
|
308
|
+
def _on_mouse_clicked(self, event: Any) -> None:
|
|
309
|
+
if event.button() != Qt.MouseButton.LeftButton or event.double():
|
|
310
|
+
return # the right button opens the context menu
|
|
311
|
+
self.click(event.scenePos())
|
|
312
|
+
|
|
313
|
+
def click(self, scene_position: QPointF) -> None:
|
|
314
|
+
"""Emit `point_clicked` for a click at `scene_position`, if it is on the plot."""
|
|
315
|
+
view_box = self.getPlotItem().getViewBox()
|
|
316
|
+
if not view_box.sceneBoundingRect().contains(scene_position):
|
|
317
|
+
return
|
|
318
|
+
position = view_box.mapSceneToView(scene_position)
|
|
319
|
+
x, y = position.x(), position.y()
|
|
320
|
+
# the view's values are their logs on a log axis
|
|
321
|
+
if self._x_log and self._kind is not ChartKind.BAR:
|
|
322
|
+
x = 10**x
|
|
323
|
+
if self._y_log:
|
|
324
|
+
y = 10**y
|
|
325
|
+
self.point_clicked.emit(float(x), float(y))
|
|
326
|
+
|
|
327
|
+
def _on_mouse_moved(self, event: tuple[Any, ...]) -> None:
|
|
328
|
+
self.show_hover(event[0])
|
|
329
|
+
|
|
330
|
+
def show_hover(self, scene_position: QPointF) -> None:
|
|
331
|
+
"""Show the values nearest `scene_position`'s x, or hide them off the plot."""
|
|
332
|
+
view_box = self.getPlotItem().vb
|
|
333
|
+
if not view_box.sceneBoundingRect().contains(scene_position) or len(self._series) == 0:
|
|
334
|
+
self._hover.setVisible(False)
|
|
335
|
+
self._crosshair.setVisible(False)
|
|
336
|
+
return
|
|
337
|
+
position = view_box.mapSceneToView(scene_position)
|
|
338
|
+
is_log = self._x_log and self._kind is not ChartKind.BAR
|
|
339
|
+
# the view's x is its log on a log axis
|
|
340
|
+
lines = self._hover_lines(10 ** position.x() if is_log else position.x())
|
|
341
|
+
if len(lines) == 0: # between a bar chart's categories, or past them
|
|
342
|
+
self._hover.setVisible(False)
|
|
343
|
+
self._crosshair.setVisible(False)
|
|
344
|
+
return
|
|
345
|
+
self._crosshair.setPos(position.x())
|
|
346
|
+
self._crosshair.setVisible(True)
|
|
347
|
+
self._hover.setText("\n".join(lines))
|
|
348
|
+
self._hover.setPos(position)
|
|
349
|
+
self._hover.setVisible(True)
|
|
350
|
+
|
|
351
|
+
def _hover_lines(self, x: float) -> list[str]:
|
|
352
|
+
"""Get what the hover shows at `x`: where it is, then each series' value there."""
|
|
353
|
+
x_unit, y_unit = self._units
|
|
354
|
+
if self._kind is ChartKind.BAR:
|
|
355
|
+
category = round(x)
|
|
356
|
+
if not 0 <= category < len(self._categories):
|
|
357
|
+
return []
|
|
358
|
+
lines = [self._categories[category]]
|
|
359
|
+
for curve in self._series:
|
|
360
|
+
heights = ChartView.bar_heights(curve, len(self._categories), y_log=self._y_log)
|
|
361
|
+
height = heights[category]
|
|
362
|
+
if math.isfinite(height):
|
|
363
|
+
lines.append(f"{curve.name}: {_format(height, y_unit)}")
|
|
364
|
+
return lines
|
|
365
|
+
lines = [_format(x, x_unit)]
|
|
366
|
+
for curve in self._series:
|
|
367
|
+
points = ChartView.drawn_points(curve, x_log=self._x_log, y_log=self._y_log)
|
|
368
|
+
if self._is_step(curve):
|
|
369
|
+
# the value held there, rather than the next point's
|
|
370
|
+
held = ChartView.held_value(*points, x)
|
|
371
|
+
y = None if held is None or math.isnan(held) else held
|
|
372
|
+
else:
|
|
373
|
+
point = _nearest(*points, x)
|
|
374
|
+
y = None if point is None else point[1]
|
|
375
|
+
if y is not None:
|
|
376
|
+
lines.append(f"{curve.name}: {_format(y, y_unit)}")
|
|
377
|
+
return lines
|
|
378
|
+
|
|
379
|
+
def _is_step(self, curve: Series) -> bool:
|
|
380
|
+
"""Tell whether `curve` is drawn in steps: a line's points only, not dots."""
|
|
381
|
+
return curve.step and ChartView.kind_of(curve, self._kind) is ChartKind.LINE
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _nearest(x_values: list[float], y_values: list[float], x: float) -> tuple[float, float] | None:
|
|
385
|
+
"""Get the drawn point with the x nearest `x`, None when none is drawn."""
|
|
386
|
+
xs = np.asarray(x_values, dtype=float)
|
|
387
|
+
ys = np.asarray(y_values, dtype=float)
|
|
388
|
+
drawn = np.isfinite(xs) & np.isfinite(ys)
|
|
389
|
+
if not drawn.any():
|
|
390
|
+
return None
|
|
391
|
+
distances = np.where(drawn, np.abs(xs - x), math.inf)
|
|
392
|
+
index = int(np.argmin(distances))
|
|
393
|
+
return float(xs[index]), float(ys[index])
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from qtpy.QtCore import QDate, QPoint, Qt
|
|
7
|
+
from qtpy.QtCore import Signal as QtSignal
|
|
8
|
+
from qtpy.QtWidgets import QCalendarWidget, QHBoxLayout, QLineEdit, QToolButton, QWidget
|
|
9
|
+
|
|
10
|
+
from composeui.core.view import ISO_DATE_FORMAT, format_date, parse_date
|
|
11
|
+
|
|
12
|
+
#: a calendar's own range, Qt's default, for a limit the view leaves out
|
|
13
|
+
_CALENDAR_FIRST, _CALENDAR_LAST = QDate(-4714, 11, 25), QDate(9999, 12, 31)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _qdate(value: datetime.date) -> QDate:
|
|
17
|
+
return QDate(value.year, value.month, value.day)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class QDateField(QWidget):
|
|
21
|
+
"""A field typing a date as `date_format` says, then a button opening a calendar.
|
|
22
|
+
|
|
23
|
+
Qt's date edit can't be empty: this field can, meaning no date. `date_edited` reports
|
|
24
|
+
the user's dates, typed or picked, and None once the field is emptied; text that isn't
|
|
25
|
+
a date reports nothing, and leaving the field puts the date back. The calendar, a
|
|
26
|
+
popup, offers the dates from `minimum` to `maximum`, named in the system's language.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
#: the user typed or picked a date (a `datetime.date`), or emptied the field (None)
|
|
30
|
+
date_edited = QtSignal(object)
|
|
31
|
+
|
|
32
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
33
|
+
super().__init__(*args, **kwargs)
|
|
34
|
+
self._date: Optional[datetime.date] = None
|
|
35
|
+
self._date_format = ISO_DATE_FORMAT
|
|
36
|
+
layout = QHBoxLayout(self)
|
|
37
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
38
|
+
layout.setSpacing(2)
|
|
39
|
+
self.line_edit = QLineEdit()
|
|
40
|
+
self.line_edit.textEdited.connect(self._read_text)
|
|
41
|
+
self.line_edit.editingFinished.connect(self._show_date)
|
|
42
|
+
layout.addWidget(self.line_edit)
|
|
43
|
+
self.button = QToolButton()
|
|
44
|
+
self.button.setArrowType(Qt.ArrowType.DownArrow)
|
|
45
|
+
self.button.clicked.connect(self.open_calendar)
|
|
46
|
+
layout.addWidget(self.button)
|
|
47
|
+
self.calendar = QCalendarWidget(self)
|
|
48
|
+
self.calendar.setWindowFlags(Qt.WindowType.Popup)
|
|
49
|
+
self.calendar.clicked.connect(self._pick)
|
|
50
|
+
self.calendar.activated.connect(self._pick) # Enter on a day
|
|
51
|
+
|
|
52
|
+
def set_date(self, value: Optional[datetime.date]) -> None:
|
|
53
|
+
self._date = value
|
|
54
|
+
self._show_date()
|
|
55
|
+
|
|
56
|
+
def set_date_format(self, date_format: str) -> None:
|
|
57
|
+
self._date_format = date_format
|
|
58
|
+
self._show_date()
|
|
59
|
+
|
|
60
|
+
def set_range(self, minimum: Optional[datetime.date], maximum: Optional[datetime.date]) -> None:
|
|
61
|
+
first = _CALENDAR_FIRST if minimum is None else _qdate(minimum)
|
|
62
|
+
last = _CALENDAR_LAST if maximum is None else _qdate(maximum)
|
|
63
|
+
self.calendar.setDateRange(first, last)
|
|
64
|
+
|
|
65
|
+
def open_calendar(self) -> None:
|
|
66
|
+
"""Show the calendar under the field, on the date, else on today's month."""
|
|
67
|
+
if self._date is not None:
|
|
68
|
+
self.calendar.setSelectedDate(_qdate(self._date))
|
|
69
|
+
else:
|
|
70
|
+
today = QDate.currentDate()
|
|
71
|
+
self.calendar.setCurrentPage(today.year(), today.month())
|
|
72
|
+
self.calendar.move(self.mapToGlobal(QPoint(0, self.height())))
|
|
73
|
+
self.calendar.show()
|
|
74
|
+
self.calendar.setFocus()
|
|
75
|
+
|
|
76
|
+
def _read_text(self, text: str) -> None:
|
|
77
|
+
if text.strip() == "":
|
|
78
|
+
self._edit(None)
|
|
79
|
+
return
|
|
80
|
+
try:
|
|
81
|
+
value = parse_date(text, self._date_format)
|
|
82
|
+
except ValueError:
|
|
83
|
+
return # not a date yet, e.g. halfway through typing one
|
|
84
|
+
self._edit(value)
|
|
85
|
+
|
|
86
|
+
def _pick(self, value: QDate) -> None:
|
|
87
|
+
# `close`, not `hide`: it also releases the popup's window, which WSLg's
|
|
88
|
+
# compositor could otherwise keep drawing (see `release_when_hidden`)
|
|
89
|
+
self.calendar.close()
|
|
90
|
+
self._edit(datetime.date(value.year(), value.month(), value.day()))
|
|
91
|
+
self._show_date()
|
|
92
|
+
|
|
93
|
+
def _edit(self, value: Optional[datetime.date]) -> None:
|
|
94
|
+
if value != self._date:
|
|
95
|
+
self._date = value
|
|
96
|
+
self.date_edited.emit(value)
|
|
97
|
+
|
|
98
|
+
def _show_date(self) -> None:
|
|
99
|
+
text = "" if self._date is None else format_date(self._date, self._date_format)
|
|
100
|
+
if self.line_edit.text() != text:
|
|
101
|
+
self.line_edit.setText(text)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from qtpy.QtCore import Qt
|
|
6
|
+
from qtpy.QtCore import Signal as QtSignal
|
|
7
|
+
from qtpy.QtWidgets import QSizePolicy, QToolButton, QVBoxLayout, QWidget
|
|
8
|
+
|
|
9
|
+
#: pixels the content is indented by, under the header's title
|
|
10
|
+
_INDENT = 20
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class QExpander(QWidget):
|
|
14
|
+
"""A collapsible section: a header button with an arrow, then its content.
|
|
15
|
+
|
|
16
|
+
The content shows only while the section is expanded. `expanded_changed` reports the
|
|
17
|
+
user's clicks on the header; `set_expanded` is quiet, and so is any change while the
|
|
18
|
+
expander's signals are blocked (the renderers block them while refreshing).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
#: the user expanded (True) or collapsed (False) the section
|
|
22
|
+
expanded_changed = QtSignal(bool)
|
|
23
|
+
|
|
24
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
25
|
+
super().__init__(*args, **kwargs)
|
|
26
|
+
layout = QVBoxLayout(self)
|
|
27
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
28
|
+
self.header = QToolButton()
|
|
29
|
+
self.header.setCheckable(True)
|
|
30
|
+
self.header.setAutoRaise(True)
|
|
31
|
+
self.header.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
|
|
32
|
+
# the whole row is the header to click, not only its text
|
|
33
|
+
self.header.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
|
34
|
+
# flat: a checkable button draws itself pressed while expanded, not a header's look
|
|
35
|
+
self.header.setStyleSheet("QToolButton { border: none; padding: 2px 0; }")
|
|
36
|
+
font = self.header.font()
|
|
37
|
+
font.setBold(True)
|
|
38
|
+
self.header.setFont(font)
|
|
39
|
+
self.header.toggled.connect(self._on_header_toggled)
|
|
40
|
+
layout.addWidget(self.header)
|
|
41
|
+
self.content = QWidget()
|
|
42
|
+
self.content_layout = QVBoxLayout(self.content)
|
|
43
|
+
# under the title, past the arrow, so a nested section reads as nested
|
|
44
|
+
self.content_layout.setContentsMargins(_INDENT, 0, 0, 0)
|
|
45
|
+
layout.addWidget(self.content)
|
|
46
|
+
self._show_expanded(is_expanded=False)
|
|
47
|
+
|
|
48
|
+
def set_expanded(self, is_expanded: bool) -> None:
|
|
49
|
+
self.header.blockSignals(True) # noqa: FBT003
|
|
50
|
+
self.header.setChecked(is_expanded)
|
|
51
|
+
self.header.blockSignals(False) # noqa: FBT003
|
|
52
|
+
self._show_expanded(is_expanded=is_expanded)
|
|
53
|
+
|
|
54
|
+
def _show_expanded(self, *, is_expanded: bool) -> None:
|
|
55
|
+
self.header.setArrowType(Qt.ArrowType.DownArrow if is_expanded else Qt.ArrowType.RightArrow)
|
|
56
|
+
self.content.setVisible(is_expanded)
|
|
57
|
+
|
|
58
|
+
def _on_header_toggled(self, is_expanded: bool) -> None:
|
|
59
|
+
self._show_expanded(is_expanded=is_expanded)
|
|
60
|
+
self.expanded_changed.emit(is_expanded)
|