This commit is contained in:
Xavier Fiechter 2025-12-18 00:32:47 +01:00
parent f18bce10b0
commit e9cd749151
7 changed files with 145 additions and 57 deletions

View file

@ -564,7 +564,7 @@ class TreeMapsView(ListView):
context['action'] = self.kwargs.get('action', 'unspent-outputs')
return context
def get_queryset(self):
def get_queryset_OLD(self):
#action = self.kwargs.get('action', 'unspent-outputs')
label_ids = []
@ -596,6 +596,38 @@ class TreeMapsView(ListView):
qs = Label.objects.none()
return qs.order_by("id")
def get_queryset(self):
label_ids = []
qs = Label.objects.filter(
labelbase__user_id=self.request.user.id,
labelbase_id=self.kwargs["pk"],
)
action = self.kwargs.get('action', 'unspent-outputs')
for l in qs:
if l.type == "output":
output = OutputStat.objects.filter(
user=l.labelbase.user,
type_ref_hash=l.type_ref_hash,
network=l.labelbase.network).last()
if output and output.spent is False:
# For fee-efficiency, only show spendable outputs
if action == 'fee-efficiency':
if l.spendable is True:
label_ids.append(l.id)
else:
label_ids.append(l.id)
if label_ids:
qs = qs.filter(id__in=label_ids,
labelbase__user_id=self.request.user.id,
labelbase_id=self.kwargs["pk"])
else:
qs = Label.objects.none()
return qs.order_by("id")
class LabelbasePortfolioView(LabelbaseView):
template_name = "labelbase_portfolio.html"

View file

@ -185,12 +185,12 @@
{% endwith %}
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_fix_and_manage" labelbase_id=labelbase.id %}"><span data-feather="git-merge" class="align-text-bottom"></span> Fix & Manage</a></li>
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "fill_missing_data" labelbase_id=labelbase.id %}"><span data-feather="refresh-cw" class="align-text-bottom"></span> Sync Fields</a></li>
{% comment %}<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_health" labelbase_id=labelbase.id %}"><span data-feather="activity" class="align-text-bottom"></span>UTXOs Health</a></li>{% endcomment %}
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_tree_maps" pk=labelbase.id %}unspent-outputs/"><span data-feather="grid" class="align-text-bottom"></span> Tree Map</a></li>
{% comment %}<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_stats_and_kpi" labelbase_id=labelbase.id %}"><span data-feather="pie-chart" class="align-text-bottom"></span> Stats & KPIs</a></li>{% endcomment %}
{% if request.user.profile.use_fiatfinances %}
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "currency_sync" labelbase_id=labelbase.id %}"><span data-feather="dollar-sign" class="align-text-bottom"></span> Fiat Finances</a></li>
{% endif %}
{% comment %}<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_health" labelbase_id=labelbase.id %}"><span data-feather="activity" class="align-text-bottom"></span>UTXOs Health</a></li>{% endcomment %}
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_tree_maps" pk=labelbase.id %}unspent-outputs/"><span data-feather="grid" class="align-text-bottom"></span> Tree Map</a></li>
{% comment %}<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_stats_and_kpi" labelbase_id=labelbase.id %}"><span data-feather="pie-chart" class="align-text-bottom"></span> Stats & KPIs</a></li>{% endcomment %}
<li><a class="nav-link" style="font-size: .875rem;" data-bs-toggle="modal" data-bs-target="#importLabelbaseModal"><span data-feather="upload-cloud" class="align-text-bottom"></span> Import</a></li>
<li><a class="nav-link" style="font-size: .875rem;" data-bs-toggle="modal" data-bs-target="#exportLabelbaseModal"><span data-feather="download-cloud" class="align-text-bottom"></span> Export</a></li>
{% if labelbase.user.profile.use_hashtags %}

View file

@ -339,7 +339,12 @@
<p>Instantly visualize the cost-effectiveness of spending your UTXOs. Labelbase calculates and displays fee health status for each spendable output, color-coded to show if transaction fees would consume a healthy percentage of the output value.</p>
</div>
</div>
<div class="col d-flex align-items-start">
<div>
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis">UTXO Fee Efficiency Visualization</h3>
<p>Visualize the cost-effectiveness of spending your UTXOs with color-coded treemaps. See at a glance which outputs have healthy fee-to-value ratios and which would be expensive to spend, helping you make informed decisions about consolidation and transaction planning.</p>
</div>
</div>
<div class="col d-flex align-items-start">
<div>

View file

@ -16,6 +16,12 @@
<li class="nav-item">
<a class="nav-link {% if action == "unspent-spendable-outputs" %}active" aria-current="page" {% else %}"{% endif %} href="{% url "labelbase_tree_maps" pk=labelbase.id %}unspent-spendable-outputs/">Unspent Spendable Outputs</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link {% if action == 'fee-efficiency' %}active{% endif %}"
href="{% url 'labelbase_tree_maps' labelbase.id 'fee-efficiency' %}">
Fee Efficiency (VTER)
</a>
</li>
</ul>

View file

@ -11,14 +11,26 @@
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.3"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-chart-treemap@0.2.3"></script>
{% if labelbase.is_testnet %}
{% if action == "fee-efficiency" %}
<div class="alert alert-dismissible bd-callout bd-callout-info">
<button type="button" class="btn-close" style="position: absolute; top: -8px; right: -4px; padding: 1.25rem 1rem;" data-bs-dismiss="alert" aria-label="Close"></button>
<div style="padding-right: 1.8rem;">
<strong>Fee Efficiency (VTER):</strong> Color shows how efficient it is to spend each output based on the fee-to-value ratio.
<br><br>
🟢 <strong>Healthy:</strong> Fee &lt; {{ request.user.profile.my_fee_threshold_healthy }}% of value<br>
🟡 <strong>Warning:</strong> Fee {{ request.user.profile.my_fee_threshold_healthy }}-{{ request.user.profile.my_fee_threshold_warning }}% of value<br>
🔴 <strong>High:</strong> Fee &gt; {{ request.user.profile.my_fee_threshold_warning }}% of value
<br><br>
<small>Box size represents output value in sats. Thresholds based on your fee settings. <a href="{% url 'userprofile_fees' %}">Adjust in Profile → Fees</a></small>
</div>
</div>
{% elif labelbase.is_testnet %}
<div class="alert alert-dismissible bd-callout bd-callout-info">
<button type="button" class="btn-close" style="position: absolute; top: -8px; right: -4px; padding: 1.25rem 1rem;" data-bs-dismiss="alert" aria-label="Close"></button>
<div style="padding-right: 1.8rem;">
<strong>Important Notice for Testnet Transactions:</strong> Testnet coins hold no real-world value and are solely intended for testing purposes.
</div>
</div
</div>
{% else %}
<div class="alert alert-dismissible bd-callout bd-callout-info ">
<button type="button" class="btn-close" style="position: absolute; top: -8px; right: -4px; padding: 1.25rem 1rem;" data-bs-dismiss="alert" aria-label="Close"></button>
@ -28,6 +40,7 @@ If the UTXO value is not provided, Labelbase will estimate it based on historica
</div>
</div>
{% endif %}
<div class="row ">
<style>
@ -40,8 +53,6 @@ If the UTXO value is not provided, Labelbase will estimate it based on historica
<canvas id="chart-area" style="height: 56vh;"></canvas>
</div>
<script type="text/javascript">
function colorFromValue(value, index, border) {
@ -53,7 +64,6 @@ function colorFromValue(value, index, border) {
} else {
var color = "#F7931A";
}
//var color = "orange";
if (border) {
alpha += 0.01;
}
@ -62,15 +72,11 @@ function colorFromValue(value, index, border) {
.rgbString();
}
function red_or_greem_fromValue(value, index, border) {
var alpha = (1 + Math.log(value)) / 5;
var color;
value = window.records[index].performance;
/* if (window.records[index].spendable == "false") {
color = "#dee2e6";
} else */
if (value > 0) {
color = "rgba(46, 204, 113, 1)"; // muted green color for positive values
} else if (value < 0) {
@ -88,12 +94,39 @@ function red_or_greem_fromValue(value, index, border) {
.rgbString();
}
window.records = [
function healthColorFromValue(value, index, border) {
var health_status = window.records[index].health_status;
var alpha = (1 + Math.log(value)) / 5;
var color;
switch(health_status) {
case 'green':
color = "rgba(46, 204, 113, 1)"; // Green
break;
case 'yellow':
color = "rgba(241, 196, 15, 1)"; // Yellow
break;
case 'red':
color = "rgba(231, 76, 60, 1)"; // Red
break;
default:
color = "#dee2e6"; // Gray - no health data
}
if (border) {
alpha += 0.01;
}
return Chart.helpers.color(color)
.alpha(alpha)
.rgbString();
}
window.records = [
{% for label in label_list %}
{% if action == "unspent-spendable-outputs" and label.spendable or action == "unspent-outputs" %}
{% if action == "unspent-spendable-outputs" and label.spendable or action == "unspent-outputs" or action == "fee-efficiency" %}
{
'spendable': {% if  label.spendable is None %}'Undefined'{% else %}'{{ label.spendable }}'{% endif %},
'spendable': {% if label.spendable is None %}'Undefined'{% else %}'{{ label.spendable }}'{% endif %},
'spent': '{{ label.get_finance_output_metrics_dict.spent }}',
'ref': '{{ label.ref }}',
'label': '{{ label.label }}',
@ -105,23 +138,24 @@ window.records = [
'type_ref_hash': '{{ label.get_finance_output_metrics_dict.type_ref_hash }}',
'performance': {{label.get_finance_output_metrics_dict.performance|floatformat:"2" }},
'current_price': {{label.get_finance_output_metrics_dict.current_price|floatformat:"2" }},
'fiat_value_old': {{label.get_finance_output_metrics_dict.fiat_value_old|floatformat:"2" }}, // historical or tracked value
'is_tracked': {{label.get_finance_output_metrics_dict.is_tracked|lower }} // tracked value
'fiat_value_old': {{label.get_finance_output_metrics_dict.fiat_value_old|floatformat:"2" }},
'is_tracked': {{label.get_finance_output_metrics_dict.is_tracked|lower }},
{% if action == "fee-efficiency" %}
'health_status': '{{ label.get_fee_health_status.status }}',
'health_display': '{{ label.get_fee_health_status_display }}',
'health_fee_sats': {{ label.get_fee_health_status.fee_sats|default:"null" }},
'health_fee_percentage': {{ label.get_fee_health_status.fee_percentage|default:"null" }},
{% endif %}
},
{% else %}
{% endif %}
{% endfor %}
];
// Sorting the array by the 'sats' property in descending order
window.records.sort(function(a, b) {
return b.sats - a.sats;
});
var ctx = document.getElementById("chart-area").getContext("2d");
window.chart1 = new Chart(ctx, {
@ -133,10 +167,18 @@ data: {
data: window.records,
key: 'sats',
backgroundColor: function(ctx) {
return red_or_greem_fromValue(ctx.dataset.data[ctx.dataIndex].v, ctx.dataIndex);
{% if action == "fee-efficiency" %}
return healthColorFromValue(ctx.dataset.data[ctx.dataIndex].v, ctx.dataIndex);
{% else %}
return red_or_greem_fromValue(ctx.dataset.data[ctx.dataIndex].v, ctx.dataIndex);
{% endif %}
},
borderColor: function(ctx) {
return red_or_greem_fromValue(ctx.dataset.data[ctx.dataIndex].v, ctx.dataIndex, true);
{% if action == "fee-efficiency" %}
return healthColorFromValue(ctx.dataset.data[ctx.dataIndex].v, ctx.dataIndex, true);
{% else %}
return red_or_greem_fromValue(ctx.dataset.data[ctx.dataIndex].v, ctx.dataIndex, true);
{% endif %}
},
spacing: 1,
borderWidth: 0,
@ -148,7 +190,7 @@ options: {
maintainAspectRatio: false,
title: {
display: true,
text: "Unspent {% if action == "unspent-spendable-outputs" %}Spendable {% endif %}Outputs"
text: "{% if action == 'fee-efficiency' %}Fee Efficiency (VTER){% elif action == 'unspent-spendable-outputs' %}Unspent Spendable Outputs{% else %}Unspent Outputs{% endif %}"
},
legend: {
display: false
@ -160,8 +202,6 @@ options: {
}
});
document.getElementById("chart-area").onclick = function(evt) {
var activePoints = window.chart1.getElementsAtEventForMode(evt, 'nearest', { intersect: true }, true);
if (activePoints[0]) {
@ -169,28 +209,26 @@ document.getElementById("chart-area").onclick = function(evt) {
}
};
// Track the current popover index
window.curr_popover_index = -1;
// Track the current popover index
window.curr_popover_index = -1;
// Define popover content
function getPopoverContent(index) {
if (index >= 0 && index < window.records.length) {
/*
var is_spent = "no";
if (window.records[index].spent) {
is_spent = "yes";
}
var is_spendable = "no";
if (window.records[index].spendable) {
is_spendable = "yes";
}
*/
var content = '<div class="popover-content">';
content += '<div><strong>Label:</strong> ' + window.records[index].label + '</div>';
content += '<div><strong>Output:</strong> ' + window.records[index].ref + '</div>';
{% if action == "fee-efficiency" %}
// Add health information for fee-efficiency view
if (window.records[index].health_status) {
content += '<div><strong>Fee Health:</strong> ' + window.records[index].health_display + '</div>';
if (window.records[index].health_fee_sats !== null) {
content += '<div><strong>Estimated Fee:</strong> ' + window.records[index].health_fee_sats + ' sats</div>';
}
}
{% endif %}
content += '<div><strong>Confirmed in block:</strong> ' + window.records[index].confirmed_at_block_height + '</div>';
if (window.records[index].is_tracked) {
content += '<div><strong>Tracked value:</strong> ' + window.records[index].fiat_cur + ' ' + window.records[index].fiat_value_old + '</div>';
@ -238,16 +276,13 @@ document.body.onmousemove = function (evt) {
var topPosition = evt.pageY - popoverHeight / 2;
var leftPosition;
if (evt.pageX <= element.getBoundingClientRect().left + element.offsetWidth / 2) {
// Inside the left half of chart-area, keep popover on the right side of the evt.pageX
leftPosition = evt.pageX + 10;
} else {
// Inside the right half of chart-area, keep popover on the left side of the evt.pageX (posx - popoverWidth = new pos x)
leftPosition = evt.pageX - popoverWidth - 10;
}
leftPosition = Math.min(leftPosition, window.innerWidth - popoverWidth - 10); // Ensure the popover doesn't exceed the right screen border
leftPosition = Math.max(leftPosition, 10); // Ensure the popover doesn't exceed the left screen border
leftPosition = Math.min(leftPosition, window.innerWidth - popoverWidth - 10);
leftPosition = Math.max(leftPosition, 10);
// Ensure that the top position is within the viewport
if (topPosition < 0) {
topPosition = 0;
} else if (topPosition + popoverHeight > window.innerHeight) {
@ -279,13 +314,10 @@ document.getElementById("chart-area").onmouseout = function (evt) {
</div>
{% else %}
NO LABELBASE
{% endif %}
<script>
{% addtoblock "js" %}
$(document).ready(function () {

View file

@ -15,9 +15,11 @@
Define the fee for new transactions. This will be used to evaluate the health of your unspent transaction outputs.
<br><br>
🟢 Healthy fee: < 1% (fee/amount < 0.01)<br>
🟡 Warning: 1-3%<br>
🔴 High: > 3%<br>
<!--(fee/amount < 0.01)<br>-->
🟢 Healthy fee: < {{ request.user.profile.my_fee_threshold_healthy }}%
🟡 Warning: {{ request.user.profile.my_fee_threshold_healthy }}-{{ user.profile.my_fee_threshold_warning }}%
🔴 High: > {{ request.user.profile.my_fee_threshold_warning }}%

View file

@ -50,9 +50,20 @@ class Profile(models.Model):
default='USD')
my_fee = models.IntegerField(default=1, help_text="Fee used when broadcasting transactions")
my_fee_rate_threshold = models.IntegerField(default=1, help_text="Overwrite healthy fee")
my_fee_rate_threshold = models.IntegerField(default=1, help_text="Adjust fee health thresholds (percentage points). Default: 0 = no adjustment, +1 = more lenient, -1 = more strict")
has_seen_welcome_popup = models.BooleanField(default=False)
@property
def my_fee_threshold_healthy(self):
"""Calculate healthy threshold with user adjustment"""
return 1.0 + self.my_fee_rate_threshold
@property
def my_fee_threshold_warning(self):
"""Calculate warning threshold with user adjustment"""
return 3.0 + self.my_fee_rate_threshold
def labelbases(self):
return Labelbase.objects.filter(user_id=self.user_id)